@underpostnet/cyberia 3.3.76 → 3.3.77
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/coverall.cyberia.ci.yml +5 -0
- package/.github/workflows/docker-image.cyberia-client.ci.yml +1 -1
- package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +1 -1
- package/.github/workflows/docker-image.cyberia-server.ci.yml +1 -1
- package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +1 -1
- package/.github/workflows/docker-image.engine-cyberia.ci.yml +1 -1
- package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +1 -1
- package/.github/workflows/engine-cyberia.ci.yml +1 -1
- package/CHANGELOG.md +35 -122
- package/CLI-HELP.md +2 -2
- package/Dockerfile +1 -1
- package/Dockerfile.dev +1 -1
- package/Dockerfile.test +1 -1
- package/README.md +1 -1
- package/bin/build.js +65 -10
- package/bin/cyberia.js +4 -4
- package/bin/index.js +4 -4
- package/conf.js +0 -1
- package/deploy/lib/config.sh +1 -1
- package/deploy/lib/host.sh +1 -1
- package/deployment.yaml +1 -1
- package/docker-compose.yml +7 -7
- package/docs/coverage/cyberia/api/cyberia-instance/cyberia-fallback-capture.js.html +1591 -0
- package/docs/coverage/cyberia/api/cyberia-instance/index.html +116 -0
- package/docs/coverage/cyberia/api/cyberia-server-defaults/cyberia-server-defaults.js.html +4138 -0
- package/docs/coverage/cyberia/api/cyberia-server-defaults/index.html +116 -0
- package/docs/coverage/cyberia/api/object-layer/index.html +116 -0
- package/docs/coverage/cyberia/api/object-layer/object-layer.model.js.html +1393 -0
- package/docs/coverage/cyberia/base.css +362 -0
- package/docs/coverage/cyberia/block-navigation.js +82 -0
- package/docs/coverage/cyberia/favicon.png +0 -0
- package/docs/coverage/cyberia/index.html +161 -0
- package/docs/coverage/cyberia/prettify.css +101 -0
- package/docs/coverage/cyberia/prettify.js +937 -0
- package/docs/coverage/cyberia/projects/cyberia/atlas-sprite-sheet-generator.js.html +1315 -0
- package/docs/coverage/cyberia/projects/cyberia/index.html +176 -0
- package/docs/coverage/cyberia/projects/cyberia/instance-backup.js.html +715 -0
- package/docs/coverage/cyberia/projects/cyberia/instance-data.js.html +2182 -0
- package/docs/coverage/cyberia/projects/cyberia/shape-generator.js.html +3265 -0
- package/docs/coverage/cyberia/projects/cyberia/stat-balance.js.html +328 -0
- package/docs/coverage/cyberia/sort-arrow-sprite.png +0 -0
- package/docs/coverage/cyberia/sorter.js +205 -0
- package/hardhat/package-lock.json +2 -2
- package/hardhat/package.json +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +1 -1
- package/manifests/deployment/dd-cyberia-development/deployment.yaml +1 -1
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/package.json +2 -2
- package/scripts/test-monitor.sh +1 -1
- package/src/cli/cluster.js +73 -18
- package/src/cli/deploy.js +211 -30
- package/src/cli/docker-compose.js +1 -1
- package/src/cli/release.js +3 -1
- package/src/cli/repository.js +7 -9
- package/src/cli/run.js +10 -3
- package/src/client/components/core/Badge.js +0 -1
- package/src/client/components/core/CssCore.js +8 -1
- package/src/client/components/core/Modal.js +29 -30
- package/src/client/components/core/ToolTip.js +15 -0
- package/src/client/components/cryptokoyn/AppShellCryptokoyn.js +0 -2
- package/src/client/components/cyberia-portal/RouterCyberiaPortal.js +0 -4
- package/src/client/components/default/AppShellDefault.js +1 -4
- package/src/client/components/itemledger/AppShellItemledger.js +0 -2
- package/src/client/components/underpost/AppShellUnderpost.js +1 -1
- package/src/client/public/cyberia-docs/CYBERIA.md +1 -1
- package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
- package/src/client/public/cyberia-docs/WHITE-PAPER.md +1 -1
- package/src/client/ssr/views/Cyberia404.js +3 -3
- package/src/index.js +1 -1
- package/src/runtime/engine-cyberia/Dockerfile +1 -1
- package/src/runtime/engine-cyberia/Dockerfile.dev +1 -1
- package/src/runtime/engine-cyberia/Dockerfile.test +1 -1
- package/src/runtime/engine-cyberia/docker-compose.yml +7 -7
- package/src/server/network/underpost-gateway.js +113 -14
- package/src/server/runtime/conf.js +36 -7
- package/src/server/runtime/start.js +11 -2
- package/test/integration/infra/4-ingress/underpost-ingress.test.js +1 -1
- package/test/unit/catalog.test.js +13 -2
- package/test/unit/conf-resolution.test.js +41 -7
- package/test/unit/cyberia/instance-data.test.js +314 -0
- package/test/unit/deploy-legacy-gateway-sweep.test.js +63 -0
- package/test/unit/gateway-pod-fetch.test.js +95 -0
- package/test/unit/letsencrypt-issuer.test.js +101 -0
- package/test/unit/release-bump.test.js +3 -1
- package/test/unit/start-options.test.js +12 -0
package/src/cli/cluster.js
CHANGED
|
@@ -36,6 +36,13 @@ import os from 'os';
|
|
|
36
36
|
import fs from 'fs-extra';
|
|
37
37
|
import Underpost from '../index.js';
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* @constant CERT_MANAGER_VERSION
|
|
41
|
+
* @description Pinned cert-manager chart version.
|
|
42
|
+
* @memberof UnderpostCluster
|
|
43
|
+
*/
|
|
44
|
+
const CERT_MANAGER_VERSION = 'v1.17.0';
|
|
45
|
+
|
|
39
46
|
const logger = loggerFactory(import.meta);
|
|
40
47
|
|
|
41
48
|
const GATEWAY_API_RELEASE = 'v1.5.1';
|
|
@@ -670,22 +677,12 @@ EOF
|
|
|
670
677
|
}
|
|
671
678
|
}
|
|
672
679
|
|
|
673
|
-
if (options.certManager)
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
--create-namespace \
|
|
680
|
-
--version v1.17.0 \
|
|
681
|
-
--set crds.enabled=true`,
|
|
682
|
-
);
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
const letsEncName = 'letsencrypt-prod';
|
|
686
|
-
shellExec(`sudo kubectl delete ClusterIssuer ${letsEncName} --ignore-not-found`);
|
|
687
|
-
shellExec(`sudo kubectl apply -f ${underpostRoot}/manifests/${letsEncName}.yaml -n ${options.namespace}`);
|
|
688
|
-
}
|
|
680
|
+
if (options.certManager)
|
|
681
|
+
Underpost.cluster.installCertManager({
|
|
682
|
+
namespace: options.namespace,
|
|
683
|
+
env: options.dev ? 'development' : 'production',
|
|
684
|
+
solver: Underpost.cluster.acmeSolverFactory({ options, presence: Underpost.cluster.ingressStackPresence() }),
|
|
685
|
+
});
|
|
689
686
|
},
|
|
690
687
|
|
|
691
688
|
/**
|
|
@@ -1100,6 +1097,57 @@ EOF
|
|
|
1100
1097
|
return true;
|
|
1101
1098
|
},
|
|
1102
1099
|
|
|
1100
|
+
/**
|
|
1101
|
+
* @method acmeSolverFactory
|
|
1102
|
+
* @description Which ingress stack answers the HTTP-01 challenge.
|
|
1103
|
+
*
|
|
1104
|
+
* Read from the cluster, like {@link UnderpostCluster.ingressStackPresence}: the
|
|
1105
|
+
* `cluster` runner installs the issuer in a separate invocation that carries no stack
|
|
1106
|
+
* flag, so the flags alone would always name the Contour solver. `--contour` is the
|
|
1107
|
+
* only explicit override; a cluster with the Gateway API data plane installed routes
|
|
1108
|
+
* every hostname through it, so an Ingress solver there is unreachable.
|
|
1109
|
+
* @param {object} params
|
|
1110
|
+
* @param {object} [params.options] - Cluster init options.
|
|
1111
|
+
* @param {{contour: boolean, gateway: boolean}} params.presence - Installed stacks.
|
|
1112
|
+
* @returns {'gateway'|'ingress'} Solver kind.
|
|
1113
|
+
* @memberof UnderpostCluster
|
|
1114
|
+
*/
|
|
1115
|
+
acmeSolverFactory({ options = {}, presence }) {
|
|
1116
|
+
if (options.contour === true && options.gatewayApi !== true) return 'ingress';
|
|
1117
|
+
return options.gatewayApi === true || presence.gateway ? 'gateway' : 'ingress';
|
|
1118
|
+
},
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* @method installCertManager
|
|
1122
|
+
* @description Installs or upgrades cert-manager and applies the Let's Encrypt
|
|
1123
|
+
* ClusterIssuer for the stack in use.
|
|
1124
|
+
*
|
|
1125
|
+
* `upgrade --install` rather than a one-time install: the Gateway API solver is
|
|
1126
|
+
* ignored by a controller that was not started with `enableGatewayAPI`, so a cluster
|
|
1127
|
+
* whose cert-manager predates the Gateway API migration must be re-configured, not
|
|
1128
|
+
* skipped because a pod is Running. The version is pinned, so on a cluster already at
|
|
1129
|
+
* it the upgrade is a no-op.
|
|
1130
|
+
* @param {object} params
|
|
1131
|
+
* @param {string} [params.namespace] - Namespace the deploys' Gateways live in.
|
|
1132
|
+
* @param {string} [params.env] - Environment the routed deploys publish under.
|
|
1133
|
+
* @param {'gateway'|'ingress'} params.solver - Which stack answers the challenge.
|
|
1134
|
+
* @returns {{solver: string, parentRefs: Array<{name: string, namespace: string}>}} What was applied.
|
|
1135
|
+
* @memberof UnderpostCluster
|
|
1136
|
+
*/
|
|
1137
|
+
installCertManager({ namespace = 'default', env = 'production', solver }) {
|
|
1138
|
+
const gatewayValues =
|
|
1139
|
+
solver === 'gateway'
|
|
1140
|
+
? ' --set config.apiVersion=controller.config.cert-manager.io/v1alpha1' +
|
|
1141
|
+
' --set config.kind=ControllerConfiguration --set config.enableGatewayAPI=true'
|
|
1142
|
+
: '';
|
|
1143
|
+
shellExec(`helm repo add jetstack https://charts.jetstack.io --force-update`);
|
|
1144
|
+
shellExec(
|
|
1145
|
+
`helm upgrade --install cert-manager jetstack/cert-manager --namespace cert-manager --create-namespace` +
|
|
1146
|
+
` --version ${CERT_MANAGER_VERSION} --set crds.enabled=true${gatewayValues} --wait --timeout 5m`,
|
|
1147
|
+
);
|
|
1148
|
+
return Underpost.deploy.applyLetsEncryptIssuer({ solver, namespace, env });
|
|
1149
|
+
},
|
|
1150
|
+
|
|
1103
1151
|
/**
|
|
1104
1152
|
* @method ingressStackPresence
|
|
1105
1153
|
* @description Which ingress data planes are installed right now.
|
|
@@ -1358,14 +1406,21 @@ EOF
|
|
|
1358
1406
|
}
|
|
1359
1407
|
|
|
1360
1408
|
try {
|
|
1361
|
-
|
|
1409
|
+
// The manifest carries the whole nginx.conf: echoing it as the command line
|
|
1410
|
+
// buries the lines that follow it — the fallback probes above all — under
|
|
1411
|
+
// a page of routing tables on every sync.
|
|
1412
|
+
logger.info('Applying the underpost ingress manifests', { namespace, node: ingressNode });
|
|
1413
|
+
shellExec(
|
|
1414
|
+
`kubectl apply -f - -n ${namespace} <<'EOF'
|
|
1362
1415
|
${underpostIngressManifestsFactory({
|
|
1363
1416
|
namespace,
|
|
1364
1417
|
conf,
|
|
1365
1418
|
nodeName: ingressNode,
|
|
1366
1419
|
})}
|
|
1367
1420
|
EOF
|
|
1368
|
-
|
|
1421
|
+
`,
|
|
1422
|
+
{ disableLog: true },
|
|
1423
|
+
);
|
|
1369
1424
|
} catch (error) {
|
|
1370
1425
|
if (shouldHotReload)
|
|
1371
1426
|
execIngress(`sh -c 'cp /tmp/nginx.previous.conf /tmp/nginx.conf && nginx -s reload -c /tmp/nginx.conf'`, {
|
package/src/cli/deploy.js
CHANGED
|
@@ -50,6 +50,8 @@ import nodePath from 'node:path';
|
|
|
50
50
|
import dotenv from 'dotenv';
|
|
51
51
|
import os from 'node:os';
|
|
52
52
|
import crypto from 'node:crypto';
|
|
53
|
+
import { fileURLToPath } from 'node:url';
|
|
54
|
+
import { dump as yamlDump, load as yamlLoad } from 'js-yaml';
|
|
53
55
|
import { appSecretName } from './app.js';
|
|
54
56
|
import { domainContextFactory } from './domains.js';
|
|
55
57
|
import Underpost from '../index.js';
|
|
@@ -93,6 +95,23 @@ const UPSTREAM_FAILURE_STATUSES = [502, 503, 504];
|
|
|
93
95
|
|
|
94
96
|
const CONTAINER_ENGINE_ROOT = '/home/dd/engine';
|
|
95
97
|
|
|
98
|
+
/**
|
|
99
|
+
* @constant LETSENCRYPT_ISSUER_NAME
|
|
100
|
+
* @description The ClusterIssuer every generated Certificate references.
|
|
101
|
+
* @memberof UnderpostDeploy
|
|
102
|
+
*/
|
|
103
|
+
const LETSENCRYPT_ISSUER_NAME = 'letsencrypt-prod';
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @constant LETSENCRYPT_ISSUER_MANIFEST
|
|
107
|
+
* @description Base ClusterIssuer manifest: account, ACME server and key Secret. Resolved
|
|
108
|
+
* from this module so an installed CLI and a checkout read the same file.
|
|
109
|
+
* @memberof UnderpostDeploy
|
|
110
|
+
*/
|
|
111
|
+
const LETSENCRYPT_ISSUER_MANIFEST = fileURLToPath(
|
|
112
|
+
new URL(`../../manifests/${LETSENCRYPT_ISSUER_NAME}.yaml`, import.meta.url),
|
|
113
|
+
);
|
|
114
|
+
|
|
96
115
|
// The manifest set `deploy --build-manifest` produces for one environment.
|
|
97
116
|
const DEFAULT_MANIFEST_FILES = [
|
|
98
117
|
'deployment.yaml',
|
|
@@ -255,12 +274,68 @@ ${buildKindPorts(fromPort, toPort)}`;
|
|
|
255
274
|
: Underpost.deploy.trafficServiceYamlFactory({ deployId, env, traffic, namespace, fromPort, toPort });
|
|
256
275
|
const escaped = `${deployId}-${env}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
257
276
|
manifest = manifest.replace(new RegExp(`app: ${escaped}-(?:blue|green)`), `app: ${deployId}-${env}-${traffic}`);
|
|
258
|
-
|
|
277
|
+
logger.info('Applying the traffic Service', { deployId, env, traffic, namespace });
|
|
278
|
+
shellExec(
|
|
279
|
+
`kubectl apply -f - -n ${namespace} <<'EOF'
|
|
259
280
|
${manifest}
|
|
260
281
|
EOF
|
|
261
|
-
|
|
282
|
+
`,
|
|
283
|
+
{ disableLog: true },
|
|
284
|
+
);
|
|
262
285
|
return Underpost.deploy.trafficServiceNameFactory({ deployId, env });
|
|
263
286
|
},
|
|
287
|
+
/**
|
|
288
|
+
* Removes the Gateway API objects an earlier model of this deploy left behind, so
|
|
289
|
+
* they stop competing with the consolidated `<deployId>-<env>` Gateway.
|
|
290
|
+
*
|
|
291
|
+
* The per-host model emitted one Gateway and one `<host>-http3` ClientTrafficPolicy
|
|
292
|
+
* per hostname. Left in place they duplicate that hostname's listeners in the same
|
|
293
|
+
* merged set, and the oldest resource retains traffic. `undefined` / `undefined-http3`
|
|
294
|
+
* is the same problem under a different name: the consolidated objects were briefly
|
|
295
|
+
* emitted with an unresolved host in their metadata, and merged listeners are
|
|
296
|
+
* configured by the oldest policy that targets them.
|
|
297
|
+
*
|
|
298
|
+
* The namespace is listed once and only objects that exist are deleted: a sweep that
|
|
299
|
+
* issued a blind delete per host per kind spent two dozen `kubectl` round-trips per
|
|
300
|
+
* sync on names that were gone after the first migration, and buried the one line
|
|
301
|
+
* that mattered when something was actually removed.
|
|
302
|
+
* @param {object} params
|
|
303
|
+
* @param {string[]} params.hosts - Hostnames the deploy's `conf.server.json` declares.
|
|
304
|
+
* @param {string} params.namespace - Namespace the deploy's Gateway objects live in.
|
|
305
|
+
* @returns {{gateways: string[], policies: string[]}} Names removed, per kind.
|
|
306
|
+
* @memberof UnderpostDeploy
|
|
307
|
+
*/
|
|
308
|
+
sweepLegacyGatewayObjects({ hosts = [], namespace = 'default' }) {
|
|
309
|
+
const existing = (kind) =>
|
|
310
|
+
new Set(
|
|
311
|
+
`${
|
|
312
|
+
shellExec(`kubectl get ${kind} -n ${namespace} -o name --ignore-not-found`, {
|
|
313
|
+
stdout: true,
|
|
314
|
+
silent: true,
|
|
315
|
+
silentOnError: true,
|
|
316
|
+
disableLog: true,
|
|
317
|
+
}) ?? ''
|
|
318
|
+
}`
|
|
319
|
+
.split('\n')
|
|
320
|
+
.map((line) => line.trim().split('/').pop())
|
|
321
|
+
.filter(Boolean),
|
|
322
|
+
);
|
|
323
|
+
const legacy = [...new Set(hosts.filter(Boolean)), 'undefined'];
|
|
324
|
+
const gateways = existing('Gateway');
|
|
325
|
+
const policies = existing('ClientTrafficPolicy');
|
|
326
|
+
const removed = {
|
|
327
|
+
gateways: legacy.filter((name) => gateways.has(name)),
|
|
328
|
+
policies: legacy.map((name) => `${name}-http3`).filter((name) => policies.has(name)),
|
|
329
|
+
};
|
|
330
|
+
for (const name of removed.gateways)
|
|
331
|
+
shellExec(`sudo kubectl delete Gateway ${name} -n ${namespace} --ignore-not-found`);
|
|
332
|
+
for (const name of removed.policies)
|
|
333
|
+
shellExec(`sudo kubectl delete ClientTrafficPolicy ${name} -n ${namespace} --ignore-not-found`);
|
|
334
|
+
if (removed.gateways.length > 0 || removed.policies.length > 0)
|
|
335
|
+
logger.warn('Legacy per-host Gateway API objects removed', { namespace, ...removed });
|
|
336
|
+
return removed;
|
|
337
|
+
},
|
|
338
|
+
|
|
264
339
|
/**
|
|
265
340
|
* Removes the route kind owned by the inactive ingress stack. The active
|
|
266
341
|
* route is already published before this runs, so migration never creates a
|
|
@@ -1112,6 +1187,125 @@ spec:
|
|
|
1112
1187
|
kind: ClusterIssuer
|
|
1113
1188
|
secretName: ${host}`;
|
|
1114
1189
|
},
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* The Gateways an ACME HTTP-01 challenge route must attach to: every Gateway the
|
|
1193
|
+
* namespace holds now, plus the one each routed deploy publishes under, whether or
|
|
1194
|
+
* not it exists yet. A challenge HTTPRoute lists them all as parents; the data plane
|
|
1195
|
+
* attaches it to whichever one carries a listener for the challenged hostname, and
|
|
1196
|
+
* reports the rest as unmatched, which is harmless.
|
|
1197
|
+
*
|
|
1198
|
+
* The union rather than either side alone: the live list covers Gateways a deploy
|
|
1199
|
+
* outside `dd.routes` created, the route table covers a deploy that has not been
|
|
1200
|
+
* applied yet — an issuer that only knew the live list would have to be re-applied
|
|
1201
|
+
* after every first deploy before that deploy's certificate could issue.
|
|
1202
|
+
* @param {object} params
|
|
1203
|
+
* @param {string} [params.namespace] - Namespace the Gateways live in.
|
|
1204
|
+
* @param {string} [params.env] - Environment the routed deploys publish under.
|
|
1205
|
+
* @returns {Array<{name: string, namespace: string}>} Parent references, sorted by name.
|
|
1206
|
+
* @memberof UnderpostDeploy
|
|
1207
|
+
*/
|
|
1208
|
+
acmeGatewayParentRefsFactory({ namespace = 'default', env = 'production' } = {}) {
|
|
1209
|
+
const live = `${
|
|
1210
|
+
shellExec(`kubectl get gateway -n ${namespace} -o name --ignore-not-found`, {
|
|
1211
|
+
stdout: true,
|
|
1212
|
+
silent: true,
|
|
1213
|
+
silentOnError: true,
|
|
1214
|
+
disableLog: true,
|
|
1215
|
+
}) ?? ''
|
|
1216
|
+
}`
|
|
1217
|
+
.split('\n')
|
|
1218
|
+
.map((line) => line.trim().split('/').pop())
|
|
1219
|
+
.filter(Boolean);
|
|
1220
|
+
const routed = readDeployRoutes().map((deployId) => Underpost.deploy.gatewayNameFactory({ deployId, env }));
|
|
1221
|
+
return [...new Set([...live, ...routed])].sort().map((name) => ({ name, namespace }));
|
|
1222
|
+
},
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* Renders the Let's Encrypt ClusterIssuer for the ingress stack that actually
|
|
1226
|
+
* carries the HTTP-01 challenge.
|
|
1227
|
+
*
|
|
1228
|
+
* The base manifest fixes the account (email, ACME server, key Secret); only the
|
|
1229
|
+
* solver is decided here. With the Gateway API stack, the challenge must be answered
|
|
1230
|
+
* through an HTTPRoute on the deploy's Gateway: an `ingress`-class solver creates an
|
|
1231
|
+
* Ingress nothing on the request path reads, so `/.well-known/acme-challenge/*` is
|
|
1232
|
+
* routed by the application's own HTTPRoute to the workload — a 404 — or, with no
|
|
1233
|
+
* workload behind it, to the maintenance fallback. Every renewal fails that way while
|
|
1234
|
+
* the solver pods sit there ready, and the failure only surfaces when the certificate
|
|
1235
|
+
* expires.
|
|
1236
|
+
* @param {object} params
|
|
1237
|
+
* @param {'gateway'|'ingress'} params.solver - Which stack answers the challenge.
|
|
1238
|
+
* @param {Array<{name: string, namespace: string}>} [params.parentRefs] - Gateways for the `gateway` solver.
|
|
1239
|
+
* @param {string} [params.basePath] - Base ClusterIssuer manifest.
|
|
1240
|
+
* @returns {string} ClusterIssuer YAML.
|
|
1241
|
+
* @throws {Error} When the Gateway solver is requested with no Gateway to attach to.
|
|
1242
|
+
* @memberof UnderpostDeploy
|
|
1243
|
+
*/
|
|
1244
|
+
letsEncryptIssuerYamlFactory({ solver, parentRefs = [], basePath = LETSENCRYPT_ISSUER_MANIFEST }) {
|
|
1245
|
+
const issuer = yamlLoad(fs.readFileSync(basePath, 'utf8'));
|
|
1246
|
+
if (solver === 'gateway') {
|
|
1247
|
+
if (parentRefs.length === 0)
|
|
1248
|
+
throw new Error('[cert-manager] the Gateway API HTTP-01 solver needs at least one Gateway parentRef');
|
|
1249
|
+
issuer.spec.acme.solvers = [
|
|
1250
|
+
{
|
|
1251
|
+
http01: {
|
|
1252
|
+
gatewayHTTPRoute: {
|
|
1253
|
+
parentRefs: parentRefs.map(({ name, namespace }) => ({ name, namespace, kind: 'Gateway' })),
|
|
1254
|
+
},
|
|
1255
|
+
},
|
|
1256
|
+
},
|
|
1257
|
+
];
|
|
1258
|
+
} else issuer.spec.acme.solvers = [{ http01: { ingress: { class: 'contour' } } }];
|
|
1259
|
+
return yamlDump(issuer, { lineWidth: -1, noRefs: true });
|
|
1260
|
+
},
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* Whether the Let's Encrypt ClusterIssuer is installed. False both when it was never
|
|
1264
|
+
* applied and when cert-manager's CRDs are absent, which is the same answer here.
|
|
1265
|
+
* @returns {boolean}
|
|
1266
|
+
* @memberof UnderpostDeploy
|
|
1267
|
+
*/
|
|
1268
|
+
letsEncryptIssuerExists() {
|
|
1269
|
+
return (
|
|
1270
|
+
`${
|
|
1271
|
+
shellExec(`kubectl get clusterissuer ${LETSENCRYPT_ISSUER_NAME} -o name --ignore-not-found`, {
|
|
1272
|
+
stdout: true,
|
|
1273
|
+
silent: true,
|
|
1274
|
+
silentOnError: true,
|
|
1275
|
+
disableLog: true,
|
|
1276
|
+
}) ?? ''
|
|
1277
|
+
}`.trim().length > 0
|
|
1278
|
+
);
|
|
1279
|
+
},
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* Applies the Let's Encrypt ClusterIssuer for the stack in use, so certificates keep
|
|
1283
|
+
* renewing through whichever data plane the deploy's hostnames are served by.
|
|
1284
|
+
* @param {object} params
|
|
1285
|
+
* @param {'gateway'|'ingress'} params.solver - Which stack answers the challenge.
|
|
1286
|
+
* @param {string} [params.namespace] - Namespace the Gateways live in.
|
|
1287
|
+
* @param {string} [params.env] - Environment the routed deploys publish under.
|
|
1288
|
+
* @param {string} [params.basePath] - Base ClusterIssuer manifest.
|
|
1289
|
+
* @returns {{solver: string, parentRefs: Array<{name: string, namespace: string}>}} What was applied.
|
|
1290
|
+
* @memberof UnderpostDeploy
|
|
1291
|
+
*/
|
|
1292
|
+
applyLetsEncryptIssuer({ solver, namespace = 'default', env = 'production', basePath } = {}) {
|
|
1293
|
+
const parentRefs = solver === 'gateway' ? Underpost.deploy.acmeGatewayParentRefsFactory({ namespace, env }) : [];
|
|
1294
|
+
const manifest = Underpost.deploy.letsEncryptIssuerYamlFactory({ solver, parentRefs, basePath });
|
|
1295
|
+
const stagePath = `/dev/shm/underpost-${LETSENCRYPT_ISSUER_NAME}.yaml`;
|
|
1296
|
+
fs.writeFileSync(stagePath, manifest, 'utf8');
|
|
1297
|
+
try {
|
|
1298
|
+
shellExec(`sudo kubectl apply -f ${stagePath}`);
|
|
1299
|
+
} finally {
|
|
1300
|
+
fs.removeSync(stagePath);
|
|
1301
|
+
}
|
|
1302
|
+
logger.info("Let's Encrypt ClusterIssuer applied", {
|
|
1303
|
+
issuer: LETSENCRYPT_ISSUER_NAME,
|
|
1304
|
+
solver,
|
|
1305
|
+
gateways: parentRefs.map(({ name }) => name),
|
|
1306
|
+
});
|
|
1307
|
+
return { solver, parentRefs };
|
|
1308
|
+
},
|
|
1115
1309
|
/**
|
|
1116
1310
|
* Mirrors each deploy's built development manifests into the project tree, and refreshes the
|
|
1117
1311
|
* public default configuration they belong to.
|
|
@@ -2341,34 +2535,15 @@ EOF`);
|
|
|
2341
2535
|
});
|
|
2342
2536
|
}
|
|
2343
2537
|
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
// one, and leaving them behind duplicates that hostname's listeners
|
|
2354
|
-
// in the same merged set. The oldest resource would retain traffic.
|
|
2355
|
-
//
|
|
2356
|
-
// `undefined-http3` is the same problem under a different name: the
|
|
2357
|
-
// consolidated policy was briefly emitted with an unresolved host in
|
|
2358
|
-
// its metadata. Merged listeners are configured by the oldest policy
|
|
2359
|
-
// that targets them, so that object outranks the correctly named one
|
|
2360
|
-
// for as long as it exists.
|
|
2361
|
-
if (options.gatewayApi)
|
|
2362
|
-
for (const name of [host, 'undefined']) {
|
|
2363
|
-
shellExec(`sudo kubectl delete Gateway ${name} -n ${namespace} --ignore-not-found`, { silent: true });
|
|
2364
|
-
shellExec(`sudo kubectl delete ClientTrafficPolicy ${name}-http3 -n ${namespace} --ignore-not-found`, {
|
|
2365
|
-
silent: true,
|
|
2366
|
-
});
|
|
2367
|
-
}
|
|
2368
|
-
if (Underpost.deploy.isCertManagerContext({ host, env, options }))
|
|
2369
|
-
shellExec(`sudo kubectl delete Certificate ${host} -n ${namespace} --ignore-not-found`);
|
|
2370
|
-
}
|
|
2371
|
-
}
|
|
2538
|
+
// The host's route object is left in place and replaced by the `apply`
|
|
2539
|
+
// below. Deleting it first unpublished the hostname for the whole
|
|
2540
|
+
// reconciliation window, so every promote dropped live requests before
|
|
2541
|
+
// the new colour was ever the question.
|
|
2542
|
+
if (!options.disableUpdateProxy && options.gatewayApi)
|
|
2543
|
+
Underpost.deploy.sweepLegacyGatewayObjects({ hosts: Object.keys(confServer), namespace });
|
|
2544
|
+
for (const host of Object.keys(confServer))
|
|
2545
|
+
if (!options.disableUpdateProxy && Underpost.deploy.isCertManagerContext({ host, env, options }))
|
|
2546
|
+
shellExec(`sudo kubectl delete Certificate ${host} -n ${namespace} --ignore-not-found`);
|
|
2372
2547
|
|
|
2373
2548
|
const manifestsPath =
|
|
2374
2549
|
env === 'production'
|
|
@@ -2480,6 +2655,11 @@ EOF`);
|
|
|
2480
2655
|
}
|
|
2481
2656
|
|
|
2482
2657
|
if (Underpost.deploy.isCertManagerContext({ host: Object.keys(confServer)[0], env, options })) {
|
|
2658
|
+
// The issuer's challenge route must list this deploy's Gateway as a parent, or
|
|
2659
|
+
// the certificates applied below can never issue. Re-rendered from the live
|
|
2660
|
+
// Gateway list, so a deploy created after `cluster --cert-manager` is covered.
|
|
2661
|
+
if (options.gatewayApi && Underpost.deploy.letsEncryptIssuerExists())
|
|
2662
|
+
Underpost.deploy.applyLetsEncryptIssuer({ solver: 'gateway', namespace, env });
|
|
2483
2663
|
const secretPath = `./${manifestsPath}/secret.yaml`;
|
|
2484
2664
|
if (fs.existsSync(secretPath) && fs.readFileSync(secretPath, 'utf8').trim()) {
|
|
2485
2665
|
shellExec(`sudo kubectl apply -f ${secretPath} -n ${namespace}`);
|
|
@@ -3143,3 +3323,4 @@ spec:
|
|
|
3143
3323
|
}
|
|
3144
3324
|
|
|
3145
3325
|
export default UnderpostDeploy;
|
|
3326
|
+
export { LETSENCRYPT_ISSUER_MANIFEST, LETSENCRYPT_ISSUER_NAME };
|
|
@@ -192,7 +192,7 @@ services:
|
|
|
192
192
|
MONGO_IMAGE=mongo:latest
|
|
193
193
|
VALKEY_IMAGE=valkey/valkey:latest
|
|
194
194
|
APP_IMAGE=underpost/underpost-engine
|
|
195
|
-
APP_TAG=v3.3.
|
|
195
|
+
APP_TAG=v3.3.77
|
|
196
196
|
PROXY_IMAGE=nginx:stable-alpine
|
|
197
197
|
PROMETHEUS_IMAGE=prom/prometheus:latest
|
|
198
198
|
GRAFANA_IMAGE=grafana/grafana:latest
|
package/src/cli/release.js
CHANGED
|
@@ -547,7 +547,9 @@ class UnderpostRelease {
|
|
|
547
547
|
logger.info(`CI push commit message: ${commitMsg}`);
|
|
548
548
|
shellExec(`node engine/bin clone --bare ${githubOrg}/${repoName}`);
|
|
549
549
|
shellCd('/home/dd/engine');
|
|
550
|
-
|
|
550
|
+
// `--coverage` runs the suites the deploy's `docs.coverage` names so the published
|
|
551
|
+
// source carries the reports; without it the pod serves the unavailable page.
|
|
552
|
+
shellExec(`node bin/build ${buildTarget} --coverage`);
|
|
551
553
|
shellCd('/home/dd/pwa-microservices-template');
|
|
552
554
|
shellExec(`rm -rf ./.git`);
|
|
553
555
|
shellExec(`mv ../${repoName}.git ./.git`);
|
package/src/cli/repository.js
CHANGED
|
@@ -2160,13 +2160,13 @@ Prevent build private config repo.`,
|
|
|
2160
2160
|
|
|
2161
2161
|
/**
|
|
2162
2162
|
* Ensures a deploy's public source repo (e.g. `engine-prototype`) is present
|
|
2163
|
-
* next to the engine
|
|
2164
|
-
*
|
|
2165
|
-
* the source tree.
|
|
2163
|
+
* next to the engine, so catalog `sourceMoves` the engine tree lacks can be
|
|
2164
|
+
* copied in.
|
|
2166
2165
|
*
|
|
2167
|
-
* Clones `../<repoName>` when missing; otherwise
|
|
2168
|
-
*
|
|
2169
|
-
*
|
|
2166
|
+
* Clones `../<repoName>` when missing; otherwise fast-forwards it. The working
|
|
2167
|
+
* tree is never reset: it is the git view of edits made in the engine tree,
|
|
2168
|
+
* which ignores those paths, so a pending change there is review material, not
|
|
2169
|
+
* something to discard.
|
|
2170
2170
|
*
|
|
2171
2171
|
* @param {string} repoName - Public source repo name (e.g. `engine-prototype`).
|
|
2172
2172
|
* @returns {boolean} `true` when the repo is available on disk.
|
|
@@ -2182,9 +2182,7 @@ Prevent build private config repo.`,
|
|
|
2182
2182
|
} else {
|
|
2183
2183
|
const repoAbsPath = path.resolve(repoPath);
|
|
2184
2184
|
shellExec(`git config --global --add safe.directory '${repoAbsPath}'`);
|
|
2185
|
-
shellExec(`cd ${repoPath} &&
|
|
2186
|
-
silent: true,
|
|
2187
|
-
});
|
|
2185
|
+
shellExec(`cd ${repoPath} && underpost pull . ${gitUri}`, { silent: true });
|
|
2188
2186
|
}
|
|
2189
2187
|
return fs.existsSync(repoPath);
|
|
2190
2188
|
},
|
package/src/cli/run.js
CHANGED
|
@@ -3079,7 +3079,7 @@ EOF`);
|
|
|
3079
3079
|
const hostListenResult = etcHostFactory(hosts);
|
|
3080
3080
|
logger.info(hostListenResult.renderHosts);
|
|
3081
3081
|
}
|
|
3082
|
-
const version = 'v3.3.
|
|
3082
|
+
const version = 'v3.3.77';
|
|
3083
3083
|
const instanceOptionsFactory = (deployId, instanceId) => ({
|
|
3084
3084
|
...options,
|
|
3085
3085
|
...clusterContextFactory(clusterType),
|
|
@@ -3518,10 +3518,17 @@ EOF`);
|
|
|
3518
3518
|
logger.info('[gateway-status] Backend probe (same Host and path, bypassing Envoy)', { backendProbes });
|
|
3519
3519
|
// Only the rule the browser actually hit is comparable, so judge on
|
|
3520
3520
|
// the root rule rather than on every rule of the route.
|
|
3521
|
+
// The gateway answer carries the `server` header (`502 (nginx)`), the
|
|
3522
|
+
// direct one is the bare code: compared as strings, every relayed status
|
|
3523
|
+
// read as a routing fault, and the data plane log was dumped on a healthy
|
|
3524
|
+
// edge whose only problem was an absent workload.
|
|
3525
|
+
const statusCode = (answer) => /^([0-9]{3})/.exec(`${answer ?? ''}`)?.[1];
|
|
3521
3526
|
const rootProbes = backendProbes.filter((entry) => entry.request.endsWith('/'));
|
|
3522
|
-
const appFault = rootProbes.filter(
|
|
3527
|
+
const appFault = rootProbes.filter(
|
|
3528
|
+
(entry) => statusCode(entry.direct) && statusCode(entry.direct) === statusCode(entry.gateway),
|
|
3529
|
+
);
|
|
3523
3530
|
const gatewayFault = rootProbes.filter(
|
|
3524
|
-
(entry) =>
|
|
3531
|
+
(entry) => statusCode(entry.direct) && statusCode(entry.direct) !== statusCode(entry.gateway),
|
|
3525
3532
|
);
|
|
3526
3533
|
if (gatewayFault.length > 0) {
|
|
3527
3534
|
logger.warn(
|
|
@@ -7,7 +7,6 @@ class Badge {
|
|
|
7
7
|
static async instance(options = { id: '', type: 'circle-red', classList: '', text: '', style: '' }) {
|
|
8
8
|
if (!options.id) options.id = getId(Badge.Tokens, 'badge-');
|
|
9
9
|
else options.id = 'badge-' + options.id;
|
|
10
|
-
if (options && options.style && !options.style.color) options.style.color = 'white';
|
|
11
10
|
if (!options.classList) options.classList = '';
|
|
12
11
|
const { id, type } = options;
|
|
13
12
|
Badge.Tokens[id] = { ...options };
|
|
@@ -53,23 +53,30 @@ const CssCommonCore = async () => {
|
|
|
53
53
|
.btn-label-content {
|
|
54
54
|
top: 15px;
|
|
55
55
|
}
|
|
56
|
+
/* Badge: one dark chip in every theme, so its lettering is always white — a caller
|
|
57
|
+
only sets a color when it also sets the background (see Badge.instance style). */
|
|
56
58
|
.badge {
|
|
57
59
|
min-width: 20px;
|
|
58
60
|
height: 20px;
|
|
59
61
|
border-radius: 3px;
|
|
60
62
|
background: #3b434b;
|
|
63
|
+
color: white;
|
|
61
64
|
position: relative;
|
|
62
65
|
font-size: 12px;
|
|
63
66
|
font-family: arial;
|
|
64
67
|
}
|
|
65
68
|
.badge-text {
|
|
66
|
-
/* color: white; */
|
|
67
69
|
padding: 3px 6px 0px 6px;
|
|
68
70
|
}
|
|
69
71
|
.badge-notification-circle-red {
|
|
70
72
|
background: #bd0c0c;
|
|
71
73
|
border-radius: 50%;
|
|
72
74
|
}
|
|
75
|
+
/* Unread dot over a menu button icon; the label reads left-to-right in both menu modes. */
|
|
76
|
+
.badge-notification-menu {
|
|
77
|
+
top: -33px;
|
|
78
|
+
left: 24px;
|
|
79
|
+
}
|
|
73
80
|
.top-box-profile-container {
|
|
74
81
|
top: 0px;
|
|
75
82
|
right: 0px;
|
|
@@ -415,16 +415,18 @@ class Modal {
|
|
|
415
415
|
},
|
|
416
416
|
{ key: `slide-menu-${idModal}` },
|
|
417
417
|
);
|
|
418
|
-
// The fixed hamburger
|
|
419
|
-
//
|
|
420
|
-
//
|
|
418
|
+
// The fixed hamburger stands in for the top bar's while it is collapsed, opening and
|
|
419
|
+
// closing the menu alike. Its 50px corner is kept clear two ways: the menu content is
|
|
420
|
+
// padded down while it shows, and a view title is padded right only while the menu
|
|
421
|
+
// is closed — an open menu (50px or full) already pushes the views aside.
|
|
421
422
|
const syncFixedMenuButton = () => {
|
|
422
423
|
const fixed = s(`.main-btn-menu-top-fixed-container`);
|
|
423
424
|
if (!fixed) return;
|
|
424
425
|
const collapsed = s(`.main-body-btn-ui-close`) && s(`.main-body-btn-ui-close`).classList.contains('hide');
|
|
425
|
-
const
|
|
426
|
-
fixed.classList[
|
|
427
|
-
s('body').classList[
|
|
426
|
+
const menuClosed = Modal.Data[idModal][options.mode].width === 0;
|
|
427
|
+
fixed.classList[collapsed ? 'remove' : 'add']('hide');
|
|
428
|
+
s('body').classList[collapsed ? 'add' : 'remove']('main-btn-menu-top-fixed-active');
|
|
429
|
+
s('body').classList[collapsed && menuClosed ? 'add' : 'remove']('main-btn-menu-top-fixed-title-offset');
|
|
428
430
|
};
|
|
429
431
|
barConfig.buttons.menu.onClick = () => {
|
|
430
432
|
Modal.Data[idModal][options.mode].width = slideMenuWidth;
|
|
@@ -548,17 +550,24 @@ class Modal {
|
|
|
548
550
|
${await BtnIcon.instance({
|
|
549
551
|
style: `height: 100%`,
|
|
550
552
|
class: `in fll main-btn-menu-top action-bar-box action-btn-center-top-fixed`,
|
|
551
|
-
label: html`<div class="abs center"
|
|
553
|
+
label: html`<div class="abs center">
|
|
554
|
+
<span class="btn-bar-center-icon-close hide">${barConfig.buttons.close.label}</span>
|
|
555
|
+
<span class="btn-bar-center-icon-menu">${barConfig.buttons.menu.label}</span>
|
|
556
|
+
</div>`,
|
|
552
557
|
})}
|
|
553
558
|
</div>
|
|
554
559
|
<style>
|
|
560
|
+
/* The menu content starts below the fixed hamburger while it shows. */
|
|
561
|
+
body.main-btn-menu-top-fixed-active .html-${idModal} {
|
|
562
|
+
padding-top: ${originHeightTopBar}px;
|
|
563
|
+
}
|
|
555
564
|
/* View modals sit at top: 0 while the top bar is collapsed; keep their
|
|
556
565
|
title clear of the fixed hamburger. The padding eases at the same pace
|
|
557
566
|
the views slide so the title moves once, not a jump and a slide back. */
|
|
558
567
|
.title-main-modal {
|
|
559
568
|
transition: padding-left 0.3s;
|
|
560
569
|
}
|
|
561
|
-
body.main-btn-menu-top-fixed-
|
|
570
|
+
body.main-btn-menu-top-fixed-title-offset .title-main-modal {
|
|
562
571
|
padding-left: ${originHeightTopBar}px;
|
|
563
572
|
}
|
|
564
573
|
</style>
|
|
@@ -2884,28 +2893,18 @@ const renderViewTitle = (
|
|
|
2884
2893
|
</div>`;
|
|
2885
2894
|
};
|
|
2886
2895
|
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
case 'right':
|
|
2901
|
-
option.style.right = '60px';
|
|
2902
|
-
break;
|
|
2903
|
-
|
|
2904
|
-
default:
|
|
2905
|
-
break;
|
|
2906
|
-
}
|
|
2907
|
-
return option;
|
|
2908
|
-
};
|
|
2896
|
+
/**
|
|
2897
|
+
* Tooltip badge for a collapsed menu button. ToolTip places it beside the button on the side
|
|
2898
|
+
* the menu leaves free, read from the menu mode when it shows — so a shell never pins an
|
|
2899
|
+
* offset that only holds for one of 'slide-menu' or 'slide-menu-right'.
|
|
2900
|
+
* @param {string} id - Translation key and tooltip id suffix.
|
|
2901
|
+
* @param {'left'|'right'} [sideKey] - Forces the side the menu sits on; omit to follow the mode.
|
|
2902
|
+
*/
|
|
2903
|
+
const buildBadgeToolTipMenuOption = (id, sideKey) => ({
|
|
2904
|
+
id: `tooltip-content-main-btn-${id}`,
|
|
2905
|
+
text: `${Translate.instance(`${id}`)}`,
|
|
2906
|
+
classList: `tooltip-menu${sideKey ? ` tooltip-menu-side-${sideKey}` : ''}`,
|
|
2907
|
+
});
|
|
2909
2908
|
|
|
2910
2909
|
const isSubMenuOpen = (subMenuId) => {
|
|
2911
2910
|
return s(`.down-arrow-submenu-${subMenuId}`) && s(`.down-arrow-submenu-${subMenuId}`).style.rotate === '180deg';
|
|
@@ -58,6 +58,21 @@ class ToolTip {
|
|
|
58
58
|
const tooltipRect = tooltipEl.getBoundingClientRect();
|
|
59
59
|
let top = containerRect.bottom + window.scrollY + 5;
|
|
60
60
|
let left = containerRect.left + window.scrollX + containerRect.width / 2 - tooltipRect.width / 2;
|
|
61
|
+
if (options.useMenuBtn) {
|
|
62
|
+
// Beside the collapsed menu button, on the side the menu leaves free: a right-hand menu
|
|
63
|
+
// sits on the window edge, so its tooltips open leftwards. A shell may pin the side.
|
|
64
|
+
const pinned = tooltipEl.querySelector('.tooltip-menu-side-right')
|
|
65
|
+
? 'right'
|
|
66
|
+
: tooltipEl.querySelector('.tooltip-menu-side-left')
|
|
67
|
+
? 'left'
|
|
68
|
+
: null;
|
|
69
|
+
const menuSide = pinned || (Modal.Data['modal-menu'].options.mode === 'slide-menu-right' ? 'right' : 'left');
|
|
70
|
+
top = containerRect.top + window.scrollY + (containerRect.height - tooltipRect.height) / 2;
|
|
71
|
+
left =
|
|
72
|
+
menuSide === 'right'
|
|
73
|
+
? containerRect.left + window.scrollX - tooltipRect.width - 10
|
|
74
|
+
: containerRect.right + window.scrollX + 10;
|
|
75
|
+
}
|
|
61
76
|
// Adjust if it goes off-screen
|
|
62
77
|
if (left < 0) left = 5;
|
|
63
78
|
if (left + tooltipRect.width > window.innerWidth) {
|