underpost 3.2.80 → 3.2.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +182 -1
- package/CLI-HELP.md +37 -16
- package/README.md +2 -2
- package/bin/deploy.js +18 -16
- package/docker-compose.yml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/playwright/deployment.yaml +1 -1
- package/manifests/mongodb/kustomization.yaml +4 -1
- package/manifests/mongodb/statefulset.yaml +4 -0
- package/manifests/mongodb/storage-class.yaml +9 -2
- package/package.json +17 -17
- package/scripts/nat-iptables.sh +10 -4
- package/scripts/test-monitor.sh +4 -3
- package/src/cli/cluster.js +740 -55
- package/src/cli/db.js +2 -2
- package/src/cli/deploy.js +1679 -174
- package/src/cli/docker-compose.js +19 -178
- package/src/cli/image.js +15 -6
- package/src/cli/index.js +124 -35
- package/src/cli/ipfs.js +82 -11
- package/src/cli/monitor.js +1 -1
- package/src/cli/repository.js +1 -1
- package/src/cli/run.js +2161 -420
- package/src/cli/secrets.js +969 -0
- package/src/cli/ssh.js +8 -28
- package/src/client-builder/client-build.js +94 -11
- package/src/client-builder/ssr.js +27 -73
- package/src/db/mongo/MongoBootstrap.js +295 -54
- package/src/db/mongo/MongooseDB.js +47 -32
- package/src/index.js +1 -1
- package/src/server/conf.js +1208 -70
- package/src/server/cri.js +70 -0
- package/src/server/underpost-gateway.js +1073 -0
- package/src/server/underpost-ingress.js +364 -0
- package/test/cluster-instances.test.js +435 -0
- package/test/deploy-node-placement.test.js +45 -0
- package/test/instance-traffic-plan.test.js +710 -0
- package/test/sops-secret-store.test.js +612 -0
- package/test/underpost-gateway.test.js +469 -0
- package/test/underpost-ingress.test.js +253 -0
package/src/cli/cluster.js
CHANGED
|
@@ -4,9 +4,18 @@
|
|
|
4
4
|
* @namespace UnderpostCluster
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { getNpmRootPath } from '../server/conf.js';
|
|
7
|
+
import { clusterTypeFactory, gatewayApiEnabledFactory, getNpmRootPath, resolveReplicaCount } from '../server/conf.js';
|
|
8
8
|
import { loggerFactory } from '../server/logger.js';
|
|
9
9
|
import { shellExec } from '../server/process.js';
|
|
10
|
+
import { crictlCommandFactory, resolveCriSocket } from '../server/cri.js';
|
|
11
|
+
import { UNDERPOST_GATEWAY, seedDefaultStatusPage } from '../server/underpost-gateway.js';
|
|
12
|
+
import {
|
|
13
|
+
UNDERPOST_INGRESS,
|
|
14
|
+
gatewayBackendFactory,
|
|
15
|
+
underpostIngressConfFactory,
|
|
16
|
+
underpostIngressHostMapFactory,
|
|
17
|
+
underpostIngressManifestsFactory,
|
|
18
|
+
} from '../server/underpost-ingress.js';
|
|
10
19
|
import { MONGODB_DEFAULT_REPLICA_COUNT } from '../db/mongo/MongooseDB.js';
|
|
11
20
|
import { MongoBootstrap } from '../db/mongo/MongoBootstrap.js';
|
|
12
21
|
import os from 'os';
|
|
@@ -15,6 +24,18 @@ import Underpost from '../index.js';
|
|
|
15
24
|
|
|
16
25
|
const logger = loggerFactory(import.meta);
|
|
17
26
|
|
|
27
|
+
// Pinned Gateway API control plane. The CRD release and the implementation are
|
|
28
|
+
// upgraded together, and the pairing is not free choice: Envoy Gateway builds
|
|
29
|
+
// against one `sigs.k8s.io/gateway-api` version (v1.8.3 -> v1.5.1) and reads
|
|
30
|
+
// fields the older CRD schemas do not define, which the API
|
|
31
|
+
// server silently strips. Check the implementation's go.mod before moving
|
|
32
|
+
// either pin.
|
|
33
|
+
const GATEWAY_API_RELEASE = 'v1.5.1';
|
|
34
|
+
const ENVOY_GATEWAY_VERSION = 'v1.8.3';
|
|
35
|
+
// Namespace the upstream Contour render deploys into, and the only one where an
|
|
36
|
+
// `app=envoy` selector resolves to its DaemonSet.
|
|
37
|
+
const CONTOUR_NAMESPACE = 'projectcontour';
|
|
38
|
+
|
|
18
39
|
/**
|
|
19
40
|
* @class UnderpostCluster
|
|
20
41
|
* @description Manages Kubernetes cluster initialization, configuration, and component deployment.
|
|
@@ -41,9 +62,11 @@ class UnderpostCluster {
|
|
|
41
62
|
* @param {boolean} [options.ipfs=false] - Deploy ipfs-cluster statefulset.
|
|
42
63
|
* @param {boolean} [options.info=false] - Display extensive Kubernetes cluster information.
|
|
43
64
|
* @param {boolean} [options.certManager=false] - Deploy Cert-Manager for certificate management.
|
|
65
|
+
* @param {boolean} [options.gatewayApi=false] - Install the Gateway API control plane (CRDs, Envoy Gateway, and the GatewayClass generated Gateways attach to). Exposure follows the environment: host network in `--dev`, NodePort otherwise.
|
|
66
|
+
* @param {string} [options.gatewayClass=''] - GatewayClass name to provision; must match the one baked into generated manifests.
|
|
44
67
|
* @param {boolean} [options.listPods=false] - List Kubernetes pods.
|
|
45
68
|
* @param {boolean} [options.reset=false] - Perform a comprehensive reset of Kubernetes and container environments.
|
|
46
|
-
* @param {boolean} [options.resetMongodb=false] - Perform a targeted reset of MongoDB components without restarting the entire cluster.
|
|
69
|
+
* @param {boolean} [options.resetMongodb=false] - Perform a targeted reset of MongoDB components without restarting the entire cluster. Combined with `--mongodb` it instead wipes the retained volumes as part of that deploy.
|
|
47
70
|
* @param {boolean} [options.dev=false] - Run in development mode (adjusts paths).
|
|
48
71
|
* @param {string} [options.nsUse=''] - Set the current kubectl namespace (creates namespace if it doesn't exist).
|
|
49
72
|
* @param {string} [options.namespace='default'] - Kubernetes namespace for cluster operations.
|
|
@@ -118,7 +141,7 @@ class UnderpostCluster {
|
|
|
118
141
|
|
|
119
142
|
if (options.config) return options.k3s ? Underpost.cluster.configMinimalK3s() : Underpost.cluster.config();
|
|
120
143
|
|
|
121
|
-
if (options.chown) return Underpost.cluster.chown(options
|
|
144
|
+
if (options.chown) return Underpost.cluster.chown(clusterTypeFactory(options));
|
|
122
145
|
|
|
123
146
|
const npmRoot = getNpmRootPath();
|
|
124
147
|
const underpostRoot = options.dev ? '.' : `${npmRoot}/underpost`;
|
|
@@ -165,9 +188,13 @@ class UnderpostCluster {
|
|
|
165
188
|
});
|
|
166
189
|
}
|
|
167
190
|
|
|
168
|
-
// Targeted MongoDB-only reset (does not restart the whole node)
|
|
169
|
-
|
|
170
|
-
|
|
191
|
+
// Targeted MongoDB-only reset (does not restart the whole node). Combined
|
|
192
|
+
// with --mongodb it is a modifier instead: the deploy below wipes the
|
|
193
|
+
// retained volumes before rolling the StatefulSet out. `--reset` cannot
|
|
194
|
+
// serve that purpose — it short-circuits into the whole-node reset above,
|
|
195
|
+
// which is why `initReplicaSet`'s reset branch was unreachable from the CLI.
|
|
196
|
+
if (options.resetMongodb && !options.mongodb) {
|
|
197
|
+
const clusterType = clusterTypeFactory(options);
|
|
171
198
|
return await MongoBootstrap.reset({
|
|
172
199
|
namespace: options.namespace,
|
|
173
200
|
clusterType,
|
|
@@ -218,17 +245,9 @@ class UnderpostCluster {
|
|
|
218
245
|
const podNetworkCidr = options.podNetworkCidr || '192.168.0.0/16';
|
|
219
246
|
const controlPlaneEndpoint = options.controlPlaneEndpoint || `${os.hostname()}:6443`;
|
|
220
247
|
|
|
221
|
-
// Initialize kubeadm control plane
|
|
222
|
-
//
|
|
223
|
-
const
|
|
224
|
-
const containerdSocket = 'unix:///run/containerd/containerd.sock';
|
|
225
|
-
const criSocket =
|
|
226
|
-
shellExec(`test -S /var/run/crio/crio.sock && echo crio || echo containerd`, {
|
|
227
|
-
stdout: true,
|
|
228
|
-
silent: true,
|
|
229
|
-
}).trim() === 'crio'
|
|
230
|
-
? crioSocket
|
|
231
|
-
: containerdSocket;
|
|
248
|
+
// Initialize kubeadm control plane against whichever CRI runtime the
|
|
249
|
+
// host actually exposes.
|
|
250
|
+
const criSocket = resolveCriSocket(options);
|
|
232
251
|
shellExec(
|
|
233
252
|
`sudo kubeadm init --pod-network-cidr=${podNetworkCidr} --control-plane-endpoint="${controlPlaneEndpoint}" --cri-socket=${criSocket}`,
|
|
234
253
|
);
|
|
@@ -258,7 +277,7 @@ class UnderpostCluster {
|
|
|
258
277
|
Underpost.cluster.natSetup({ underpostRoot });
|
|
259
278
|
// Kind cluster initialization (default for development)
|
|
260
279
|
logger.info('Initializing Kind cluster...');
|
|
261
|
-
const devReplicaCount =
|
|
280
|
+
const devReplicaCount = resolveReplicaCount(options.replicas, MONGODB_DEFAULT_REPLICA_COUNT);
|
|
262
281
|
shellExec(`sudo mkdir -p /data/mongodb`);
|
|
263
282
|
for (let index = 0; index < devReplicaCount; index++) {
|
|
264
283
|
shellExec(`sudo mkdir -p /data/mongodb/v${index}`);
|
|
@@ -310,7 +329,7 @@ class UnderpostCluster {
|
|
|
310
329
|
.readFileSync(`${underpostRoot}/manifests/grafana/deployment.yaml`, 'utf8')
|
|
311
330
|
.replace('{{GF_SERVER_ROOT_URL}}', options.hosts.split(',')[0])}`;
|
|
312
331
|
console.log(yaml);
|
|
313
|
-
shellExec(`kubectl apply -f - -n ${options.namespace} <<EOF
|
|
332
|
+
shellExec(`kubectl apply -f - -n ${options.namespace} <<'EOF'
|
|
314
333
|
${yaml}
|
|
315
334
|
EOF
|
|
316
335
|
`);
|
|
@@ -329,7 +348,7 @@ EOF
|
|
|
329
348
|
.join(',')}]`,
|
|
330
349
|
)}`;
|
|
331
350
|
console.log(yaml);
|
|
332
|
-
shellExec(`kubectl apply -f - -n ${options.namespace} <<EOF
|
|
351
|
+
shellExec(`kubectl apply -f - -n ${options.namespace} <<'EOF'
|
|
333
352
|
${yaml}
|
|
334
353
|
EOF
|
|
335
354
|
`);
|
|
@@ -361,9 +380,14 @@ EOF
|
|
|
361
380
|
await Underpost.ipfs.deploy(options, underpostRoot);
|
|
362
381
|
}
|
|
363
382
|
if (options.mariadb) {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
383
|
+
// Secrets before workloads: the StatefulSet's secretKeyRef must resolve at pod admission.
|
|
384
|
+
// Encrypted store first; when no manifest exists the secret is seeded from its origin
|
|
385
|
+
// seed path below. A manifest that exists but fails validation raises rather than
|
|
386
|
+
// silently seeding stale credentials.
|
|
387
|
+
if (!Underpost.secret.sops.applyIfPresent('mariadb-secret', options.namespace))
|
|
388
|
+
shellExec(
|
|
389
|
+
`sudo kubectl create secret generic mariadb-secret --from-file=username=/home/dd/engine/engine-private/mariadb-username --from-file=password=/home/dd/engine/engine-private/mariadb-password --dry-run=client -o yaml | kubectl apply -f - -n ${options.namespace}`,
|
|
390
|
+
);
|
|
367
391
|
shellExec(`kubectl delete statefulset mariadb-statefulset -n ${options.namespace} --ignore-not-found`);
|
|
368
392
|
|
|
369
393
|
if (options.pullImage) Underpost.cluster.pullImage('mariadb:latest', options);
|
|
@@ -371,9 +395,10 @@ EOF
|
|
|
371
395
|
shellExec(`kubectl apply -k ${underpostRoot}/manifests/mariadb -n ${options.namespace}`);
|
|
372
396
|
}
|
|
373
397
|
if (options.mysql) {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
398
|
+
if (!Underpost.secret.sops.applyIfPresent('mysql-secret', options.namespace))
|
|
399
|
+
shellExec(
|
|
400
|
+
`sudo kubectl create secret generic mysql-secret --from-file=username=/home/dd/engine/engine-private/mysql-username --from-file=password=/home/dd/engine/engine-private/mysql-password --dry-run=client -o yaml | kubectl apply -f - -n ${options.namespace}`,
|
|
401
|
+
);
|
|
377
402
|
shellExec(`sudo mkdir -p /mnt/data`);
|
|
378
403
|
shellExec(`sudo chmod 777 /mnt/data`);
|
|
379
404
|
shellExec(`sudo chown -R $(whoami):$(whoami) /mnt/data`);
|
|
@@ -381,9 +406,10 @@ EOF
|
|
|
381
406
|
}
|
|
382
407
|
if (options.postgresql) {
|
|
383
408
|
if (options.pullImage) Underpost.cluster.pullImage('postgres:latest', options);
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
409
|
+
if (!Underpost.secret.sops.applyIfPresent('postgres-secret', options.namespace))
|
|
410
|
+
shellExec(
|
|
411
|
+
`sudo kubectl create secret generic postgres-secret --from-file=password=/home/dd/engine/engine-private/postgresql-password --dry-run=client -o yaml | kubectl apply -f - -n ${options.namespace}`,
|
|
412
|
+
);
|
|
387
413
|
shellExec(`kubectl apply -k ${underpostRoot}/manifests/postgresql -n ${options.namespace}`);
|
|
388
414
|
}
|
|
389
415
|
if (options.mongodb4) {
|
|
@@ -441,13 +467,13 @@ EOF
|
|
|
441
467
|
});
|
|
442
468
|
}
|
|
443
469
|
} else if (options.mongodb) {
|
|
444
|
-
const clusterType = options
|
|
470
|
+
const clusterType = clusterTypeFactory(options);
|
|
445
471
|
await MongoBootstrap.initReplicaSet({
|
|
446
472
|
namespace: options.namespace,
|
|
447
|
-
replicaCount:
|
|
473
|
+
replicaCount: resolveReplicaCount(options.replicas, MONGODB_DEFAULT_REPLICA_COUNT),
|
|
448
474
|
hostList: serviceHostInput,
|
|
449
475
|
pullImage: options.pullImage,
|
|
450
|
-
reset: options.
|
|
476
|
+
reset: options.resetMongodb === true,
|
|
451
477
|
clusterType,
|
|
452
478
|
underpostRoot,
|
|
453
479
|
});
|
|
@@ -459,20 +485,160 @@ EOF
|
|
|
459
485
|
});
|
|
460
486
|
}
|
|
461
487
|
|
|
488
|
+
// Installing one stack must never break the other. Whichever is already
|
|
489
|
+
// present decides whether this install takes the node's 80/443 for itself
|
|
490
|
+
// or joins the underpost ingress, so it is read before anything is applied.
|
|
491
|
+
const ingressPresence = Underpost.cluster.ingressStackPresence();
|
|
492
|
+
const sharedIngress =
|
|
493
|
+
(options.contour && ingressPresence.gateway) || (options.gatewayApi && ingressPresence.contour);
|
|
494
|
+
|
|
462
495
|
if (options.contour) {
|
|
463
496
|
shellExec(
|
|
464
497
|
`kubectl apply -f https://cdn.jsdelivr.net/gh/projectcontour/contour@release-1.33/examples/render/contour.yaml`,
|
|
465
498
|
);
|
|
499
|
+
// The NodePort patch belongs to Contour's own namespace: its `app=envoy`
|
|
500
|
+
// selector only matches the Envoy DaemonSet there. Applied anywhere else
|
|
501
|
+
// the Service is born with no endpoints, and kube-proxy then answers
|
|
502
|
+
// EVERY connection to its ports with an ICMP port-unreachable REJECT —
|
|
503
|
+
// including 443 on every local address, which silently breaks whatever
|
|
504
|
+
// else serves HTTPS on the node.
|
|
505
|
+
Underpost.cluster.pruneEndpointlessService({ name: 'envoy', namespace: options.namespace });
|
|
506
|
+
// Contour's render always claims hostPort 80/443. Left in place beside a
|
|
507
|
+
// Gateway API data plane it would DNAT every packet away from it, so the
|
|
508
|
+
// claim is released and both are reached through the underpost ingress instead.
|
|
509
|
+
if (sharedIngress)
|
|
510
|
+
Underpost.cluster.releaseHostPortClaim({ name: 'envoy', namespace: CONTOUR_NAMESPACE, ports: [80, 443] });
|
|
466
511
|
if (options.kubeadm) {
|
|
467
512
|
// Envoy service might need NodePort for kubeadm
|
|
468
513
|
shellExec(
|
|
469
|
-
`sudo kubectl apply -f ${underpostRoot}/manifests/envoy-service-nodeport.yaml -n ${
|
|
514
|
+
`sudo kubectl apply -f ${underpostRoot}/manifests/envoy-service-nodeport.yaml -n ${CONTOUR_NAMESPACE}`,
|
|
470
515
|
);
|
|
471
516
|
}
|
|
472
517
|
// K3s has a built-in LoadBalancer (Klipper-lb) that can expose services,
|
|
473
518
|
// so a specific NodePort service might not be needed or can be configured differently.
|
|
474
519
|
}
|
|
475
520
|
|
|
521
|
+
if (options.gatewayApi) {
|
|
522
|
+
// Gateway API stack: the CRDs, the implementation that serves them, and
|
|
523
|
+
// the GatewayClass every generated Gateway attaches to. Envoy Gateway is
|
|
524
|
+
// the implementation because the manifests this repo generates use two of
|
|
525
|
+
// its ClientTrafficPolicy extension, which carries the QUIC/HTTP3
|
|
526
|
+
// listener config. Both versions are pinned so a rebuild provisions the
|
|
527
|
+
// same control plane.
|
|
528
|
+
const env = options.dev ? 'development' : 'production';
|
|
529
|
+
// Envoy Gateway provisions and owns its own data plane Service in
|
|
530
|
+
// envoy-gateway-system. Any hand-managed Service elsewhere that publishes
|
|
531
|
+
// the same ports without endpoints would have kube-proxy reject 80/443
|
|
532
|
+
// out from under it, and any DaemonSet holding hostPort 80/443 would
|
|
533
|
+
// swallow the traffic before the data plane's listener sees it.
|
|
534
|
+
Underpost.cluster.pruneEndpointlessService({ name: 'envoy', namespace: options.namespace });
|
|
535
|
+
// Contour keeps serving beside this stack when it is already installed —
|
|
536
|
+
// it only gives up the node's ports, which the underpost ingress takes over.
|
|
537
|
+
// With no Contour present the claim is removed outright, since a leftover
|
|
538
|
+
// DaemonSet from an uninstalled stack has nothing left to serve.
|
|
539
|
+
if (sharedIngress)
|
|
540
|
+
Underpost.cluster.releaseHostPortClaim({ name: 'envoy', namespace: CONTOUR_NAMESPACE, ports: [80, 443] });
|
|
541
|
+
else Underpost.cluster.pruneHostPortClaim({ name: 'envoy', namespace: CONTOUR_NAMESPACE, ports: [80, 443] });
|
|
542
|
+
shellExec(
|
|
543
|
+
`kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/${GATEWAY_API_RELEASE}/standard-install.yaml`,
|
|
544
|
+
);
|
|
545
|
+
shellExec(
|
|
546
|
+
`helm upgrade --install eg oci://docker.io/envoyproxy/gateway-helm --version ${ENVOY_GATEWAY_VERSION} ` +
|
|
547
|
+
`--namespace envoy-gateway-system --create-namespace`,
|
|
548
|
+
);
|
|
549
|
+
// The GatewayClass is rejected while the webhook is still coming up.
|
|
550
|
+
shellExec(
|
|
551
|
+
`kubectl wait --namespace envoy-gateway-system --for=condition=Available --timeout=5m deployment/envoy-gateway`,
|
|
552
|
+
{ silentOnError: true },
|
|
553
|
+
);
|
|
554
|
+
// Validate against the live CRD schema before creating anything. These
|
|
555
|
+
// are vendor CRDs whose optional fields move between releases, and
|
|
556
|
+
// kubectl applies documents independently: without the pre-flight, a
|
|
557
|
+
// field the installed Envoy Gateway does not know leaves a GatewayClass
|
|
558
|
+
// pointing at an EnvoyProxy that never applied.
|
|
559
|
+
const gatewayClassYaml = Underpost.deploy.gatewayClassYamlFactory({
|
|
560
|
+
env,
|
|
561
|
+
options: { ...options, sharedIngress },
|
|
562
|
+
});
|
|
563
|
+
shellExec(`kubectl apply --dry-run=server -f - <<'EOF'
|
|
564
|
+
${gatewayClassYaml}
|
|
565
|
+
EOF
|
|
566
|
+
`);
|
|
567
|
+
shellExec(`kubectl apply -f - <<'EOF'
|
|
568
|
+
${gatewayClassYaml}
|
|
569
|
+
EOF
|
|
570
|
+
`);
|
|
571
|
+
// The static utility that serves status pages and intercepted contexts.
|
|
572
|
+
// It belongs to the gateway tier, not to any one deploy: a single Nginx
|
|
573
|
+
// holds every host's documents, and routes reach it as an ordinary
|
|
574
|
+
// backend — which is what removes the 4096-byte direct-response ceiling.
|
|
575
|
+
//
|
|
576
|
+
// The heredoc delimiter is quoted, as it must be for every generated
|
|
577
|
+
// manifest: the values are already substituted by the template literal,
|
|
578
|
+
// so anything the shell expands here is content. This one carries
|
|
579
|
+
// `nginx.conf`, whose `$uri` an unquoted delimiter deletes — leaving a
|
|
580
|
+
// `try_files` that matches nothing and answers every host's status page
|
|
581
|
+
// with the shared default.
|
|
582
|
+
const underpostGatewayYaml = Underpost.deploy.underpostGatewayYamlFactory(options);
|
|
583
|
+
shellExec(`kubectl apply -f - -n ${options.namespace} <<'EOF'
|
|
584
|
+
${underpostGatewayYaml}
|
|
585
|
+
EOF
|
|
586
|
+
`);
|
|
587
|
+
const underpostGatewayRoot = Underpost.deploy.underpostGatewayRootFactory(options);
|
|
588
|
+
seedDefaultStatusPage(underpostGatewayRoot);
|
|
589
|
+
logger.info('Gateway static utility applied', {
|
|
590
|
+
name: UNDERPOST_GATEWAY.name,
|
|
591
|
+
root: underpostGatewayRoot,
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
logger.info('Gateway API control plane installed', {
|
|
595
|
+
gatewayApiRelease: GATEWAY_API_RELEASE,
|
|
596
|
+
envoyGateway: ENVOY_GATEWAY_VERSION,
|
|
597
|
+
gatewayClass: Underpost.deploy.gatewayApiConfigFactory(options).gatewayClassName,
|
|
598
|
+
env,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Both stacks are now installed, so the node's 80/443 belong to neither of
|
|
603
|
+
// them. Contour was installed first and Envoy Gateway is arriving, or the
|
|
604
|
+
// reverse — either way this is the point where the underpost ingress has to exist,
|
|
605
|
+
// and the data plane that just came up has to stop binding the host.
|
|
606
|
+
if (sharedIngress) {
|
|
607
|
+
if (options.contour && ingressPresence.gateway) {
|
|
608
|
+
// `--contour` against a cluster that already runs Envoy Gateway: its
|
|
609
|
+
// data plane is still on the host network from a single-stack install,
|
|
610
|
+
// so the EnvoyProxy is re-applied to move it behind the underpost ingress.
|
|
611
|
+
const gatewayClassYaml = Underpost.deploy.gatewayClassYamlFactory({
|
|
612
|
+
env: options.dev ? 'development' : 'production',
|
|
613
|
+
options: { ...options, sharedIngress: true },
|
|
614
|
+
});
|
|
615
|
+
shellExec(`kubectl apply -f - <<'EOF'
|
|
616
|
+
${gatewayClassYaml}
|
|
617
|
+
EOF
|
|
618
|
+
`);
|
|
619
|
+
shellExec(`kubectl rollout status deployment/envoy-gateway -n envoy-gateway-system --timeout=5m`, {
|
|
620
|
+
silentOnError: true,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
// The edge binds the node directly, so it can only come up once both data
|
|
624
|
+
// planes have actually let go — not merely once their templates say so.
|
|
625
|
+
if (!Underpost.cluster.awaitHostPortsFree({ ports: [80, 443] })) {
|
|
626
|
+
logger.error('Skipping the underpost ingress: it would not be able to bind the node ports', {
|
|
627
|
+
hint: 'resolve the holder above, then re-run this command',
|
|
628
|
+
});
|
|
629
|
+
} else {
|
|
630
|
+
Underpost.cluster.installUnderpostIngress({ namespace: options.namespace, options });
|
|
631
|
+
shellExec(
|
|
632
|
+
`kubectl rollout status deployment/${UNDERPOST_INGRESS.name} -n ${options.namespace} --timeout=3m`,
|
|
633
|
+
{ silentOnError: true },
|
|
634
|
+
);
|
|
635
|
+
logger.info('Both ingress stacks are live behind the underpost ingress', {
|
|
636
|
+
ingress: UNDERPOST_INGRESS.name,
|
|
637
|
+
note: 'HTTP/3 is served only by the Gateway API data plane; see the underpost-ingress module',
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
476
642
|
if (options.certManager) {
|
|
477
643
|
if (!Underpost.kubectl.get('cert-manager').find((p) => p.STATUS === 'Running')) {
|
|
478
644
|
shellExec(`helm repo add jetstack https://charts.jetstack.io --force-update`);
|
|
@@ -637,27 +803,540 @@ EOF
|
|
|
637
803
|
`for node in $(kind get nodes); do cat ${tarPath} | docker exec -i $node ctr --namespace=k8s.io images import -; done`,
|
|
638
804
|
);
|
|
639
805
|
shellExec(`rm -f ${tarPath}`);
|
|
640
|
-
} else
|
|
641
|
-
//
|
|
642
|
-
|
|
806
|
+
} else {
|
|
807
|
+
// Kubeadm / K3s: pull directly into the active CRI runtime.
|
|
808
|
+
shellExec(crictlCommandFactory(`pull ${image}`, options));
|
|
809
|
+
}
|
|
810
|
+
},
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* @method pruneHostPortClaim
|
|
814
|
+
* @description Removes a DaemonSet that reserves node ports the caller needs.
|
|
815
|
+
* A `hostPort` is not a soft claim: the CNI hostport plugin installs a DNAT
|
|
816
|
+
* that rewrites every packet arriving on that port to the claiming pod,
|
|
817
|
+
* ahead of any process listening on the node. A second ingress stack bound
|
|
818
|
+
* to the same port therefore never sees a single packet, and the symptom is
|
|
819
|
+
* a connection refused by the *other* stack — with nothing in its own logs.
|
|
820
|
+
*
|
|
821
|
+
* Only a DaemonSet that actually claims one of `ports` is removed.
|
|
822
|
+
* @param {string} name - DaemonSet name.
|
|
823
|
+
* @param {string} namespace - Namespace to inspect.
|
|
824
|
+
* @param {Array<number>} ports - Host ports the caller requires.
|
|
825
|
+
* @returns {boolean} True when a DaemonSet was pruned.
|
|
826
|
+
* @memberof UnderpostCluster
|
|
827
|
+
*/
|
|
828
|
+
pruneHostPortClaim({ name, namespace, ports = [80, 443] }) {
|
|
829
|
+
const claimed = shellExec(
|
|
830
|
+
`kubectl get daemonset ${name} -n ${namespace} ` +
|
|
831
|
+
`-o jsonpath='{.spec.template.spec.containers[*].ports[*].hostPort}'`,
|
|
832
|
+
{ stdout: true, silent: true, silentOnError: true },
|
|
833
|
+
);
|
|
834
|
+
const claimedPorts = `${claimed || ''}`
|
|
835
|
+
.split(/\s+/)
|
|
836
|
+
.map((port) => parseInt(port, 10))
|
|
837
|
+
.filter((port) => !isNaN(port));
|
|
838
|
+
const conflicts = claimedPorts.filter((port) => ports.includes(port));
|
|
839
|
+
if (conflicts.length === 0) return false;
|
|
840
|
+
logger.warn(`Pruning DaemonSet ${namespace}/${name}: it reserves host ports needed by this ingress stack`, {
|
|
841
|
+
conflicts,
|
|
842
|
+
reason: 'the CNI hostport DNAT redirects those ports before any node listener receives them',
|
|
843
|
+
});
|
|
844
|
+
shellExec(`kubectl delete daemonset ${name} -n ${namespace} --ignore-not-found`);
|
|
845
|
+
return true;
|
|
846
|
+
},
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* @method releaseHostPortClaim
|
|
850
|
+
* @description Strips a DaemonSet's `hostPort` claims without removing the
|
|
851
|
+
* workload, so it keeps serving through its ClusterIP.
|
|
852
|
+
*
|
|
853
|
+
* The non-destructive half of {@link UnderpostCluster.pruneHostPortClaim}.
|
|
854
|
+
* Deleting the DaemonSet is right when one stack replaces the other, and
|
|
855
|
+
* wrong when both are meant to stay: here the data plane is still wanted,
|
|
856
|
+
* it just must not hold the node's ports any more — the underpost ingress does.
|
|
857
|
+
*
|
|
858
|
+
* Applied as a JSON Patch, one `remove` op per claiming port. A
|
|
859
|
+
* strategic-merge patch cannot express this: `ports` is a list merged by
|
|
860
|
+
* `containerPort`, so a patch that simply omits `hostPort` merges into the
|
|
861
|
+
* existing element and leaves the claim exactly where it was — reported as
|
|
862
|
+
* `patched (no change)` while the port stays held. Removing an object field
|
|
863
|
+
* does not shift array indices, so every op in one patch stays valid.
|
|
864
|
+
* @param {string} name - DaemonSet name.
|
|
865
|
+
* @param {string} namespace - Namespace to inspect.
|
|
866
|
+
* @param {Array<number>} [ports] - Host ports to release.
|
|
867
|
+
* @returns {boolean} True when the claim is gone afterwards.
|
|
868
|
+
* @memberof UnderpostCluster
|
|
869
|
+
*/
|
|
870
|
+
releaseHostPortClaim({ name, namespace, ports = [80, 443] }) {
|
|
871
|
+
const readClaims = () => {
|
|
872
|
+
const raw = shellExec(
|
|
873
|
+
`kubectl get daemonset ${name} -n ${namespace} -o jsonpath='{.spec.template.spec.containers}'`,
|
|
874
|
+
{ stdout: true, silent: true, silentOnError: true },
|
|
875
|
+
);
|
|
876
|
+
try {
|
|
877
|
+
const containers = JSON.parse(`${raw || ''}`);
|
|
878
|
+
return Array.isArray(containers) ? containers : null;
|
|
879
|
+
} catch {
|
|
880
|
+
return null;
|
|
881
|
+
}
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
const containers = readClaims();
|
|
885
|
+
if (!containers) return false;
|
|
886
|
+
const operations = [];
|
|
887
|
+
containers.forEach((container, containerIndex) =>
|
|
888
|
+
(container.ports || []).forEach((port, portIndex) => {
|
|
889
|
+
if (port.hostPort === undefined || !ports.includes(port.hostPort)) return;
|
|
890
|
+
operations.push({
|
|
891
|
+
op: 'remove',
|
|
892
|
+
path: `/spec/template/spec/containers/${containerIndex}/ports/${portIndex}/hostPort`,
|
|
893
|
+
});
|
|
894
|
+
}),
|
|
895
|
+
);
|
|
896
|
+
if (operations.length === 0) return false;
|
|
897
|
+
|
|
898
|
+
logger.warn(`Releasing host ports on DaemonSet ${namespace}/${name}: the underpost ingress owns them now`, {
|
|
899
|
+
ports,
|
|
900
|
+
reason:
|
|
901
|
+
'its ClusterIP keeps the data plane reachable, and the hostPort DNAT would outrank the underpost ingress listener',
|
|
902
|
+
});
|
|
903
|
+
shellExec(`kubectl patch daemonset ${name} -n ${namespace} --type=json -p '${JSON.stringify(operations)}'`);
|
|
904
|
+
|
|
905
|
+
// Verified rather than assumed: this silently did nothing once already,
|
|
906
|
+
// and the failure only surfaced later as an unschedulable edge.
|
|
907
|
+
const remaining = (readClaims() || [])
|
|
908
|
+
.flatMap((container) => container.ports || [])
|
|
909
|
+
.filter((port) => ports.includes(port.hostPort));
|
|
910
|
+
if (remaining.length > 0) {
|
|
911
|
+
logger.error(`Failed to release host ports on DaemonSet ${namespace}/${name}`, {
|
|
912
|
+
stillClaimed: remaining.map((port) => port.hostPort),
|
|
913
|
+
});
|
|
914
|
+
return false;
|
|
915
|
+
}
|
|
916
|
+
shellExec(`kubectl rollout status daemonset/${name} -n ${namespace} --timeout=3m`, { silentOnError: true });
|
|
917
|
+
return true;
|
|
918
|
+
},
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* @method awaitHostPortsFree
|
|
922
|
+
* @description Blocks until no known ingress data plane still holds the node's ports.
|
|
923
|
+
*
|
|
924
|
+
* Releasing a claim edits a template; the pod holding the socket goes away
|
|
925
|
+
* only once the rollout completes. Applying the edge before that leaves it
|
|
926
|
+
* either unschedulable or crash-looping on `bind() … Address in use`, and
|
|
927
|
+
* neither failure names the pod that is actually holding the port.
|
|
928
|
+
* @param {Array<number>} [ports] - Host ports that must be free.
|
|
929
|
+
* @param {string} [contourNamespace] - Namespace holding Contour.
|
|
930
|
+
* @param {number} [timeoutMs] - How long to wait.
|
|
931
|
+
* @returns {boolean} True once nothing claims them.
|
|
932
|
+
* @memberof UnderpostCluster
|
|
933
|
+
*/
|
|
934
|
+
awaitHostPortsFree({ ports = [80, 443], contourNamespace = CONTOUR_NAMESPACE, timeoutMs = 3 * 60 * 1000 } = {}) {
|
|
935
|
+
const deadline = Date.now() + timeoutMs;
|
|
936
|
+
const holders = () => {
|
|
937
|
+
const found = [];
|
|
938
|
+
const contourPorts = `${
|
|
939
|
+
shellExec(
|
|
940
|
+
`kubectl get daemonset envoy -n ${contourNamespace} ` +
|
|
941
|
+
`-o jsonpath='{.spec.template.spec.containers[*].ports[*].hostPort}'`,
|
|
942
|
+
{ stdout: true, silent: true, silentOnError: true },
|
|
943
|
+
) || ''
|
|
944
|
+
}`
|
|
945
|
+
.split(/\s+/)
|
|
946
|
+
.map((port) => parseInt(port, 10))
|
|
947
|
+
.filter((port) => ports.includes(port));
|
|
948
|
+
if (contourPorts.length > 0) found.push(`${contourNamespace}/envoy hostPort ${contourPorts.join(',')}`);
|
|
949
|
+
// The Gateway API data plane takes the ports through the host network
|
|
950
|
+
// rather than a hostPort, so its claim is the network mode itself.
|
|
951
|
+
const gatewayHostNetwork = `${
|
|
952
|
+
shellExec(
|
|
953
|
+
`kubectl get deployment -n envoy-gateway-system -l app.kubernetes.io/name=envoy ` +
|
|
954
|
+
`-o jsonpath='{.items[*].spec.template.spec.hostNetwork}'`,
|
|
955
|
+
{ stdout: true, silent: true, silentOnError: true },
|
|
956
|
+
) || ''
|
|
957
|
+
}`.trim();
|
|
958
|
+
if (gatewayHostNetwork.includes('true')) found.push('envoy-gateway-system data plane hostNetwork');
|
|
959
|
+
return found;
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
let current = holders();
|
|
963
|
+
while (current.length > 0 && Date.now() < deadline) {
|
|
964
|
+
logger.info('Waiting for the node ports to be released', { ports, heldBy: current });
|
|
965
|
+
shellExec('sleep 5', { silent: true });
|
|
966
|
+
current = holders();
|
|
967
|
+
}
|
|
968
|
+
if (current.length > 0) {
|
|
969
|
+
logger.error('Node ports are still held; the underpost ingress cannot bind them', {
|
|
970
|
+
ports,
|
|
971
|
+
heldBy: current,
|
|
972
|
+
});
|
|
973
|
+
return false;
|
|
974
|
+
}
|
|
975
|
+
return true;
|
|
976
|
+
},
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* @method ingressStackPresence
|
|
980
|
+
* @description Which ingress data planes are installed right now.
|
|
981
|
+
*
|
|
982
|
+
* Read from the cluster rather than from the flags, because the whole point
|
|
983
|
+
* is to react to what a previous invocation left behind: `--contour` run
|
|
984
|
+
* against a cluster that already has Envoy Gateway has to behave differently
|
|
985
|
+
* from `--contour` on an empty one.
|
|
986
|
+
* @param {string} [contourNamespace] - Namespace holding Contour.
|
|
987
|
+
* @returns {{contour: boolean, gateway: boolean}} Presence of each stack.
|
|
988
|
+
* @memberof UnderpostCluster
|
|
989
|
+
*/
|
|
990
|
+
ingressStackPresence(contourNamespace = CONTOUR_NAMESPACE) {
|
|
991
|
+
const exists = (cmd) =>
|
|
992
|
+
`${shellExec(cmd, { stdout: true, silent: true, silentOnError: true }) || ''}`.trim().length > 0;
|
|
993
|
+
return {
|
|
994
|
+
contour: exists(`kubectl get daemonset envoy -n ${contourNamespace} -o name`),
|
|
995
|
+
gateway: exists(`kubectl get deployment envoy-gateway -n envoy-gateway-system -o name`),
|
|
996
|
+
};
|
|
997
|
+
},
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* @method refreshUnderpostIngress
|
|
1001
|
+
* @description Rebuilds the underpost ingress host table after routes change.
|
|
1002
|
+
*
|
|
1003
|
+
* The table maps each hostname to the data plane that has a route object for
|
|
1004
|
+
* it, so it goes stale the moment a host is published — a hostname added
|
|
1005
|
+
* after the last build falls through to the default backend, which is the
|
|
1006
|
+
* *other* stack, and answers 404 for a workload that is running perfectly.
|
|
1007
|
+
* Every path that publishes a route therefore ends here.
|
|
1008
|
+
*
|
|
1009
|
+
* A no-op when the ingress is not installed, which is the single-stack case:
|
|
1010
|
+
* there is no table to keep, because the one data plane owns the ports.
|
|
1011
|
+
* The rendered config is unchanged by a blue/green promotion — the table is
|
|
1012
|
+
* host to stack, not host to colour — so an ordinary deploy re-applies the
|
|
1013
|
+
* same bytes and nothing restarts.
|
|
1014
|
+
* @param {string} [namespace] - Namespace holding the ingress.
|
|
1015
|
+
* @param {object} [options] - Cluster options. `ingressNode` is the only
|
|
1016
|
+
* placement override; application `node` / `nodeName` flags are ignored.
|
|
1017
|
+
* @returns {boolean} True when the table was rebuilt.
|
|
1018
|
+
* @memberof UnderpostCluster
|
|
1019
|
+
*/
|
|
1020
|
+
refreshUnderpostIngress({ namespace = 'default', options = {} } = {}) {
|
|
1021
|
+
const installed = `${
|
|
1022
|
+
shellExec(`kubectl get deployment ${UNDERPOST_INGRESS.name} -n ${namespace} -o name`, {
|
|
1023
|
+
stdout: true,
|
|
1024
|
+
silent: true,
|
|
1025
|
+
silentOnError: true,
|
|
1026
|
+
}) || ''
|
|
1027
|
+
}`.trim();
|
|
1028
|
+
if (!installed) return false;
|
|
1029
|
+
return Underpost.cluster.installUnderpostIngress({ namespace, options });
|
|
1030
|
+
},
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* @method installUnderpostIngress
|
|
1034
|
+
* @description Installs or refreshes the underpost ingress in front of both data planes.
|
|
1035
|
+
*
|
|
1036
|
+
* The host table is built from the route objects that actually exist, so a
|
|
1037
|
+
* hostname reaches the stack that describes it no matter which flag was used
|
|
1038
|
+
* last. Rebuilt on every call, which is what keeps it correct after routes
|
|
1039
|
+
* move between stacks.
|
|
1040
|
+
* @param {string} [namespace] - Namespace to deploy the underpost ingress into.
|
|
1041
|
+
* @param {object} [options] - Cluster options. Use `ingressNode` to
|
|
1042
|
+
* explicitly place or recover the public listener.
|
|
1043
|
+
* @returns {boolean} True when the underpost ingress was applied.
|
|
1044
|
+
* @memberof UnderpostCluster
|
|
1045
|
+
*/
|
|
1046
|
+
installUnderpostIngress({ namespace = 'default', options = {} } = {}) {
|
|
1047
|
+
const presence = Underpost.cluster.ingressStackPresence();
|
|
1048
|
+
// Each kind names its hosts in its own field, so each is read with its own
|
|
1049
|
+
// jsonpath rather than one expression that happens to tolerate the other's
|
|
1050
|
+
// shape being absent.
|
|
1051
|
+
const HOST_FIELD = { httpproxy: '.spec.virtualhost.fqdn', httproute: '.spec.hostnames[*]' };
|
|
1052
|
+
const hostsOf = (kind) =>
|
|
1053
|
+
`${
|
|
1054
|
+
shellExec(`kubectl get ${kind} -A -o jsonpath='{range .items[*]}{${HOST_FIELD[kind]}}{" "}{end}'`, {
|
|
1055
|
+
stdout: true,
|
|
1056
|
+
silent: true,
|
|
1057
|
+
silentOnError: true,
|
|
1058
|
+
}) || ''
|
|
1059
|
+
}`
|
|
1060
|
+
.split(/\s+/)
|
|
1061
|
+
.map((host) => host.trim())
|
|
1062
|
+
.filter(Boolean);
|
|
1063
|
+
|
|
1064
|
+
const backends = {};
|
|
1065
|
+
if (presence.contour) backends.contour = UNDERPOST_INGRESS.backends.contour;
|
|
1066
|
+
if (presence.gateway) {
|
|
1067
|
+
// Envoy Gateway names its own data plane Service, so it is discovered
|
|
1068
|
+
// rather than assumed; without it the underpost ingress would proxy to nothing.
|
|
1069
|
+
const service = `${
|
|
1070
|
+
shellExec(
|
|
1071
|
+
`kubectl get svc -n envoy-gateway-system -l app.kubernetes.io/name=envoy -o jsonpath='{.items[0].metadata.name}'`,
|
|
1072
|
+
{ stdout: true, silent: true, silentOnError: true },
|
|
1073
|
+
) || ''
|
|
1074
|
+
}`.trim();
|
|
1075
|
+
if (service) backends.gateway = gatewayBackendFactory(service);
|
|
1076
|
+
else logger.warn('Envoy Gateway is installed but has provisioned no data plane Service yet');
|
|
1077
|
+
}
|
|
1078
|
+
if (Object.keys(backends).length === 0) {
|
|
1079
|
+
logger.warn('No ingress data plane installed; skipping the underpost ingress');
|
|
1080
|
+
return false;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const { entries, conflicts } = underpostIngressHostMapFactory({
|
|
1084
|
+
contourHosts: presence.contour ? hostsOf('httpproxy') : [],
|
|
1085
|
+
gatewayHosts: presence.gateway ? hostsOf('httproute') : [],
|
|
1086
|
+
// During migration both route kinds deliberately coexist. Point the
|
|
1087
|
+
// host at the destination stack before the old object is deleted; a
|
|
1088
|
+
// fixed Gateway preference would create a 404 window when migrating in
|
|
1089
|
+
// the opposite direction (Gateway API -> Contour).
|
|
1090
|
+
preferred: gatewayApiEnabledFactory(options) ? 'gateway' : 'contour',
|
|
1091
|
+
});
|
|
1092
|
+
if (conflicts.length > 0)
|
|
1093
|
+
logger.warn('Hosts described by both stacks; the destination stack wins until the old route is removed', {
|
|
1094
|
+
conflicts,
|
|
1095
|
+
destination: gatewayApiEnabledFactory(options) ? 'gateway' : 'contour',
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
const conf = underpostIngressConfFactory({
|
|
1099
|
+
entries,
|
|
1100
|
+
backends,
|
|
1101
|
+
resolver: Underpost.deploy.clusterDnsFactory(),
|
|
1102
|
+
defaultBackend: backends.gateway ? 'gateway' : 'contour',
|
|
1103
|
+
});
|
|
1104
|
+
const liveNode = `${
|
|
643
1105
|
shellExec(
|
|
644
|
-
`
|
|
1106
|
+
`kubectl get deployment ${UNDERPOST_INGRESS.name} -n ${namespace} ` +
|
|
1107
|
+
`-o jsonpath='{.spec.template.spec.nodeSelector.kubernetes\\.io/hostname}'`,
|
|
1108
|
+
{ stdout: true, silent: true, silentOnError: true },
|
|
1109
|
+
) || ''
|
|
1110
|
+
}`.trim();
|
|
1111
|
+
// `node` and `nodeName` place application workloads. Reusing either here
|
|
1112
|
+
// relocates the public listener during an ordinary deploy; with Recreate
|
|
1113
|
+
// that first deletes the healthy edge and can leave the whole site on
|
|
1114
|
+
// connection-refused if the destination cannot pull Nginx. Only the
|
|
1115
|
+
// dedicated ingressNode option may move this workload. Every route-table
|
|
1116
|
+
// refresh otherwise preserves the established edge node.
|
|
1117
|
+
const requestedNode = options.ingressNode || '';
|
|
1118
|
+
const chosenNode =
|
|
1119
|
+
requestedNode ||
|
|
1120
|
+
liveNode ||
|
|
1121
|
+
Underpost.deploy.resolveDeployNode({
|
|
1122
|
+
node: '',
|
|
1123
|
+
kind: options.kind,
|
|
1124
|
+
kubeadm: options.kubeadm,
|
|
1125
|
+
k3s: options.k3s,
|
|
1126
|
+
env: options.dev ? 'development' : 'production',
|
|
1127
|
+
});
|
|
1128
|
+
// The chosen name is a guess unless it came from `--ingress-node`: the
|
|
1129
|
+
// cluster-type default reads `--dev` as kind, and `liveNode` re-reads
|
|
1130
|
+
// whatever a previous run wrote. This workload is pinned by `nodeSelector`
|
|
1131
|
+
// with `hostNetwork`, so a name no node carries does not degrade — the pod
|
|
1132
|
+
// stays Pending and every rollout wait times out.
|
|
1133
|
+
const { node: ingressNode, corrected } = Underpost.deploy.resolveSchedulableNode({ node: chosenNode });
|
|
1134
|
+
if (corrected && requestedNode)
|
|
1135
|
+
throw new Error(
|
|
1136
|
+
`[underpost-ingress] --ingress-node ${requestedNode} is not a node in this cluster (schedulable: ${ingressNode})`,
|
|
645
1137
|
);
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
1138
|
+
if (corrected)
|
|
1139
|
+
logger.warn('Ingress node does not exist in this cluster; using a schedulable node instead', {
|
|
1140
|
+
chosen: chosenNode,
|
|
1141
|
+
from: liveNode === chosenNode ? 'the live deployment' : 'the cluster-type default',
|
|
1142
|
+
using: ingressNode,
|
|
1143
|
+
});
|
|
1144
|
+
const liveMount = `${
|
|
1145
|
+
shellExec(
|
|
1146
|
+
`kubectl get deployment ${UNDERPOST_INGRESS.name} -n ${namespace} ` +
|
|
1147
|
+
`-o jsonpath='{.spec.template.spec.containers[0].volumeMounts[?(@.name=="nginx-conf")].mountPath}'`,
|
|
1148
|
+
{ stdout: true, silent: true, silentOnError: true },
|
|
1149
|
+
) || ''
|
|
1150
|
+
}`.trim();
|
|
1151
|
+
const liveReady = `${
|
|
1152
|
+
shellExec(
|
|
1153
|
+
`kubectl get deployment ${UNDERPOST_INGRESS.name} -n ${namespace} -o jsonpath='{.status.readyReplicas}'`,
|
|
1154
|
+
{ stdout: true, silent: true, silentOnError: true },
|
|
1155
|
+
) || ''
|
|
1156
|
+
}`.trim();
|
|
1157
|
+
const canHotReload = liveMount === '/etc/underpost-ingress' && parseInt(liveReady || '0', 10) > 0;
|
|
1158
|
+
const changesNode = !!liveNode && ingressNode !== liveNode;
|
|
1159
|
+
const execIngress = (command, execOptions = {}) =>
|
|
1160
|
+
shellExec(`kubectl exec -n ${namespace} deploy/${UNDERPOST_INGRESS.name} -- ${command}`, execOptions);
|
|
1161
|
+
|
|
1162
|
+
if (changesNode) {
|
|
1163
|
+
// Prove the destination can start the exact edge image before Recreate
|
|
1164
|
+
// removes the listener that is serving now. This pod has no hostNetwork,
|
|
1165
|
+
// so it cannot contend for 80/443. On an offline node, IfNotPresent uses
|
|
1166
|
+
// the cache; a missing image fails here while the current edge remains.
|
|
1167
|
+
const preflightPod = `${UNDERPOST_INGRESS.name}-image-preflight`;
|
|
1168
|
+
shellExec(`kubectl delete pod ${preflightPod} -n ${namespace} --ignore-not-found`, {
|
|
1169
|
+
silent: true,
|
|
1170
|
+
silentOnError: true,
|
|
1171
|
+
});
|
|
1172
|
+
try {
|
|
1173
|
+
shellExec(`kubectl apply -f - -n ${namespace} <<'EOF'
|
|
1174
|
+
apiVersion: v1
|
|
1175
|
+
kind: Pod
|
|
1176
|
+
metadata:
|
|
1177
|
+
name: ${preflightPod}
|
|
1178
|
+
namespace: ${namespace}
|
|
1179
|
+
spec:
|
|
1180
|
+
restartPolicy: Never
|
|
1181
|
+
nodeSelector:
|
|
1182
|
+
kubernetes.io/hostname: ${ingressNode}
|
|
1183
|
+
containers:
|
|
1184
|
+
- name: nginx
|
|
1185
|
+
image: ${UNDERPOST_INGRESS.image}
|
|
1186
|
+
imagePullPolicy: IfNotPresent
|
|
1187
|
+
command: ['/bin/sh', '-c', 'nginx -v && sleep 300']
|
|
1188
|
+
EOF
|
|
1189
|
+
`);
|
|
1190
|
+
shellExec(`kubectl wait --for=condition=Ready pod/${preflightPod} -n ${namespace} --timeout=2m`, {
|
|
1191
|
+
silent: true,
|
|
1192
|
+
});
|
|
1193
|
+
} finally {
|
|
1194
|
+
shellExec(`kubectl delete pod ${preflightPod} -n ${namespace} --ignore-not-found`, {
|
|
1195
|
+
silent: true,
|
|
1196
|
+
silentOnError: true,
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
const liveConf = canHotReload
|
|
1201
|
+
? `${execIngress('cat /tmp/nginx.conf', {
|
|
652
1202
|
stdout: true,
|
|
653
1203
|
silent: true,
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
1204
|
+
silentOnError: true,
|
|
1205
|
+
}) || ''}`
|
|
1206
|
+
: '';
|
|
1207
|
+
const shouldHotReload = canHotReload && liveConf.trimEnd() !== `${conf}`.trimEnd();
|
|
1208
|
+
|
|
1209
|
+
if (shouldHotReload) {
|
|
1210
|
+
// Validate the exact candidate before either the running master or the
|
|
1211
|
+
// persisted ConfigMap sees it. The quoted heredoc preserves every Nginx
|
|
1212
|
+
// variable and keeps a malformed host table from reaching the edge.
|
|
1213
|
+
shellExec(`kubectl exec -i -n ${namespace} deploy/${UNDERPOST_INGRESS.name} -- sh -c 'cat > /tmp/nginx.candidate.conf' <<'EOF'
|
|
1214
|
+
${conf}
|
|
1215
|
+
EOF
|
|
1216
|
+
`);
|
|
1217
|
+
execIngress('nginx -t -c /tmp/nginx.candidate.conf', { silent: true });
|
|
1218
|
+
try {
|
|
1219
|
+
execIngress(
|
|
1220
|
+
`sh -c 'cp /tmp/nginx.conf /tmp/nginx.previous.conf && ` +
|
|
1221
|
+
`cp /tmp/nginx.candidate.conf /tmp/nginx.conf && nginx -s reload -c /tmp/nginx.conf'`,
|
|
1222
|
+
{ silent: true },
|
|
1223
|
+
);
|
|
1224
|
+
} catch (error) {
|
|
1225
|
+
execIngress(
|
|
1226
|
+
`sh -c 'cp /tmp/nginx.previous.conf /tmp/nginx.conf && nginx -s reload -c /tmp/nginx.conf'`,
|
|
1227
|
+
{ silent: true, silentOnError: true },
|
|
1228
|
+
);
|
|
1229
|
+
throw error;
|
|
1230
|
+
}
|
|
658
1231
|
}
|
|
1232
|
+
|
|
1233
|
+
try {
|
|
1234
|
+
shellExec(`kubectl apply -f - -n ${namespace} <<'EOF'
|
|
1235
|
+
${underpostIngressManifestsFactory({
|
|
1236
|
+
namespace,
|
|
1237
|
+
conf,
|
|
1238
|
+
nodeName: ingressNode,
|
|
1239
|
+
})}
|
|
1240
|
+
EOF
|
|
1241
|
+
`);
|
|
1242
|
+
} catch (error) {
|
|
1243
|
+
if (shouldHotReload)
|
|
1244
|
+
execIngress(
|
|
1245
|
+
`sh -c 'cp /tmp/nginx.previous.conf /tmp/nginx.conf && nginx -s reload -c /tmp/nginx.conf'`,
|
|
1246
|
+
{ silent: true, silentOnError: true },
|
|
1247
|
+
);
|
|
1248
|
+
throw error;
|
|
1249
|
+
}
|
|
1250
|
+
if (shouldHotReload)
|
|
1251
|
+
execIngress('rm -f /tmp/nginx.previous.conf /tmp/nginx.candidate.conf', {
|
|
1252
|
+
silent: true,
|
|
1253
|
+
silentOnError: true,
|
|
1254
|
+
});
|
|
1255
|
+
// A live pod before apply is not proof the desired template rolled out.
|
|
1256
|
+
// In particular, changing nodeSelector uses Recreate and invalidates the
|
|
1257
|
+
// ready replica that made canHotReload true. Do not let the caller delete
|
|
1258
|
+
// the old route kind until the replacement edge is actually Available.
|
|
1259
|
+
if (!canHotReload || changesNode)
|
|
1260
|
+
shellExec(
|
|
1261
|
+
`kubectl rollout status deployment/${UNDERPOST_INGRESS.name} -n ${namespace} --timeout=5m`,
|
|
1262
|
+
{ silent: true },
|
|
1263
|
+
);
|
|
1264
|
+
else if (shouldHotReload)
|
|
1265
|
+
// `nginx -s reload` returns after signalling the master. Give it one
|
|
1266
|
+
// scheduling turn to start the new workers before the caller removes
|
|
1267
|
+
// the old stack's route object.
|
|
1268
|
+
shellExec('sleep 1', { silent: true });
|
|
1269
|
+
shellExec(
|
|
1270
|
+
`kubectl wait --for=condition=Available deployment/${UNDERPOST_INGRESS.name} ` +
|
|
1271
|
+
`-n ${namespace} --timeout=5m`,
|
|
1272
|
+
{ silent: true },
|
|
1273
|
+
);
|
|
1274
|
+
execIngress('nginx -t -c /tmp/nginx.conf', { silent: true });
|
|
1275
|
+
logger.info('Underpost ingress applied', {
|
|
1276
|
+
namespace,
|
|
1277
|
+
backends: Object.keys(backends),
|
|
1278
|
+
hosts: entries.length,
|
|
1279
|
+
node: ingressNode,
|
|
1280
|
+
hotReloaded: shouldHotReload,
|
|
1281
|
+
});
|
|
1282
|
+
return true;
|
|
1283
|
+
},
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* @method pruneEndpointlessService
|
|
1287
|
+
* @description Removes a Service that resolves to nothing. kube-proxy does
|
|
1288
|
+
* not ignore such a Service — it installs an ICMP port-unreachable REJECT
|
|
1289
|
+
* for every port it publishes, so an orphan is not inert: it actively
|
|
1290
|
+
* refuses connections on those ports, and the refusal looks exactly like
|
|
1291
|
+
* "no server is listening" even while one is.
|
|
1292
|
+
*
|
|
1293
|
+
* Only an endpointless Service is removed, so a healthy one of the same name
|
|
1294
|
+
* is never touched.
|
|
1295
|
+
* @param {string} name - Service name.
|
|
1296
|
+
* @param {string} [namespace] - Namespace to inspect.
|
|
1297
|
+
* @returns {boolean} True when a Service was pruned.
|
|
1298
|
+
* @memberof UnderpostCluster
|
|
1299
|
+
*/
|
|
1300
|
+
pruneEndpointlessService({ name, namespace = 'default' }) {
|
|
1301
|
+
const exists = shellExec(`kubectl get svc ${name} -n ${namespace} -o name`, {
|
|
1302
|
+
stdout: true,
|
|
1303
|
+
silent: true,
|
|
1304
|
+
silentOnError: true,
|
|
1305
|
+
});
|
|
1306
|
+
if (!exists || !`${exists}`.trim()) return false;
|
|
1307
|
+
// EndpointSlice rather than the deprecated Endpoints API.
|
|
1308
|
+
const addresses = shellExec(
|
|
1309
|
+
`kubectl get endpointslice -n ${namespace} -l kubernetes.io/service-name=${name} ` +
|
|
1310
|
+
`-o jsonpath='{.items[*].endpoints[*].addresses[*]}'`,
|
|
1311
|
+
{ stdout: true, silent: true, silentOnError: true },
|
|
1312
|
+
);
|
|
1313
|
+
if (addresses && `${addresses}`.trim()) return false;
|
|
1314
|
+
logger.warn(`Pruning endpointless Service ${namespace}/${name}`, {
|
|
1315
|
+
reason: 'kube-proxy REJECTs every port a Service with no endpoints publishes',
|
|
1316
|
+
});
|
|
1317
|
+
shellExec(`kubectl delete svc ${name} -n ${namespace} --ignore-not-found`);
|
|
1318
|
+
return true;
|
|
659
1319
|
},
|
|
660
1320
|
|
|
1321
|
+
/**
|
|
1322
|
+
* @method resolveCriSocket
|
|
1323
|
+
* @description CLI-facing binding of {@link CriEndpoint.resolveCriSocket}.
|
|
1324
|
+
* @param {object} [options] - Cluster options (`k3s`, `criSocket`).
|
|
1325
|
+
* @returns {string} CRI endpoint URI.
|
|
1326
|
+
* @memberof UnderpostCluster
|
|
1327
|
+
*/
|
|
1328
|
+
resolveCriSocket,
|
|
1329
|
+
|
|
1330
|
+
/**
|
|
1331
|
+
* @method crictlCommandFactory
|
|
1332
|
+
* @description CLI-facing binding of {@link CriEndpoint.crictlCommandFactory}.
|
|
1333
|
+
* @param {string} args - crictl subcommand and arguments (e.g. `pull mongo:latest`).
|
|
1334
|
+
* @param {object} [options] - Cluster options (`k3s`, `criSocket`).
|
|
1335
|
+
* @returns {string} Full shell command.
|
|
1336
|
+
* @memberof UnderpostCluster
|
|
1337
|
+
*/
|
|
1338
|
+
crictlCommandFactory,
|
|
1339
|
+
|
|
661
1340
|
/**
|
|
662
1341
|
* @method config
|
|
663
1342
|
* @description Configures host-level settings required for Kubernetes.
|
|
@@ -889,7 +1568,7 @@ net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-k3s.conf > /dev/null`,
|
|
|
889
1568
|
* @param {object} [options]
|
|
890
1569
|
* @param {boolean} [options.all=true] - Remove all unused images, not just dangling ones.
|
|
891
1570
|
* @param {boolean} [options.crictl=false] - Also prune the CRI runtime via crictl.
|
|
892
|
-
* @param {string} [options.criSocket] - Optional
|
|
1571
|
+
* @param {string} [options.criSocket] - Optional CRI endpoint override; otherwise the live runtime is resolved.
|
|
893
1572
|
* @private
|
|
894
1573
|
*/
|
|
895
1574
|
_pruneContainerCaches(options = {}) {
|
|
@@ -906,11 +1585,9 @@ net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-k3s.conf > /dev/null`,
|
|
|
906
1585
|
silentOnError: true,
|
|
907
1586
|
});
|
|
908
1587
|
if (options.crictl) {
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
{ silentOnError: true },
|
|
913
|
-
);
|
|
1588
|
+
shellExec(`if command -v crictl >/dev/null 2>&1; then ${crictlCommandFactory('rmi --prune', options)}; fi`, {
|
|
1589
|
+
silentOnError: true,
|
|
1590
|
+
});
|
|
914
1591
|
}
|
|
915
1592
|
Underpost.cluster._unmountOrphanContainerOverlays();
|
|
916
1593
|
},
|
|
@@ -1039,11 +1716,15 @@ fi`);
|
|
|
1039
1716
|
Underpost.cluster._lazyUmountKubeletMounts();
|
|
1040
1717
|
|
|
1041
1718
|
logger.info('Phase 4/7: Killing control-plane processes and running kubeadm reset...');
|
|
1042
|
-
shellExec(`if command -v crictl >/dev/null 2>&1; then
|
|
1719
|
+
shellExec(`if command -v crictl >/dev/null 2>&1; then ${crictlCommandFactory('rm -a -f')}; fi`, {
|
|
1720
|
+
silentOnError: true,
|
|
1721
|
+
});
|
|
1043
1722
|
// Remove CNI config before stopping sandboxes so Calico's CNI delete hook is
|
|
1044
1723
|
// not invoked (the API server is already down and the hook would fail).
|
|
1045
1724
|
shellExec(`sudo rm -rf /etc/cni/net.d/*`);
|
|
1046
|
-
shellExec(`if command -v crictl >/dev/null 2>&1; then
|
|
1725
|
+
shellExec(`if command -v crictl >/dev/null 2>&1; then ${crictlCommandFactory('rmp -a -f')}; fi`, {
|
|
1726
|
+
silentOnError: true,
|
|
1727
|
+
});
|
|
1047
1728
|
shellExec(`if systemctl is-active --quiet etcd; then sudo systemctl stop etcd; fi`);
|
|
1048
1729
|
for (const port of [6443, 10259, 10257, 2379, 2380]) {
|
|
1049
1730
|
shellExec(`if sudo fuser ${port}/tcp >/dev/null 2>&1; then sudo fuser -k ${port}/tcp; fi`);
|
|
@@ -1226,6 +1907,10 @@ EOF`);
|
|
|
1226
1907
|
shellExec(`sudo rm -rf get_helm.sh`);
|
|
1227
1908
|
}
|
|
1228
1909
|
|
|
1910
|
+
// SOPS and Age for Git-native encrypted secret manifests. Owned by UnderpostSecret so the
|
|
1911
|
+
// same idempotent install backs `underpost secret --install-tools`.
|
|
1912
|
+
Underpost.secret.sops.installTooling();
|
|
1913
|
+
|
|
1229
1914
|
// Install snap
|
|
1230
1915
|
shellExec(`sudo yum install -y snapd`);
|
|
1231
1916
|
shellExec(`sudo systemctl enable --now snapd.socket`);
|