@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.
Files changed (86) hide show
  1. package/.github/workflows/coverall.cyberia.ci.yml +5 -0
  2. package/.github/workflows/docker-image.cyberia-client.ci.yml +1 -1
  3. package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +1 -1
  4. package/.github/workflows/docker-image.cyberia-server.ci.yml +1 -1
  5. package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +1 -1
  6. package/.github/workflows/docker-image.engine-cyberia.ci.yml +1 -1
  7. package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +1 -1
  8. package/.github/workflows/engine-cyberia.ci.yml +1 -1
  9. package/CHANGELOG.md +35 -122
  10. package/CLI-HELP.md +2 -2
  11. package/Dockerfile +1 -1
  12. package/Dockerfile.dev +1 -1
  13. package/Dockerfile.test +1 -1
  14. package/README.md +1 -1
  15. package/bin/build.js +65 -10
  16. package/bin/cyberia.js +4 -4
  17. package/bin/index.js +4 -4
  18. package/conf.js +0 -1
  19. package/deploy/lib/config.sh +1 -1
  20. package/deploy/lib/host.sh +1 -1
  21. package/deployment.yaml +1 -1
  22. package/docker-compose.yml +7 -7
  23. package/docs/coverage/cyberia/api/cyberia-instance/cyberia-fallback-capture.js.html +1591 -0
  24. package/docs/coverage/cyberia/api/cyberia-instance/index.html +116 -0
  25. package/docs/coverage/cyberia/api/cyberia-server-defaults/cyberia-server-defaults.js.html +4138 -0
  26. package/docs/coverage/cyberia/api/cyberia-server-defaults/index.html +116 -0
  27. package/docs/coverage/cyberia/api/object-layer/index.html +116 -0
  28. package/docs/coverage/cyberia/api/object-layer/object-layer.model.js.html +1393 -0
  29. package/docs/coverage/cyberia/base.css +362 -0
  30. package/docs/coverage/cyberia/block-navigation.js +82 -0
  31. package/docs/coverage/cyberia/favicon.png +0 -0
  32. package/docs/coverage/cyberia/index.html +161 -0
  33. package/docs/coverage/cyberia/prettify.css +101 -0
  34. package/docs/coverage/cyberia/prettify.js +937 -0
  35. package/docs/coverage/cyberia/projects/cyberia/atlas-sprite-sheet-generator.js.html +1315 -0
  36. package/docs/coverage/cyberia/projects/cyberia/index.html +176 -0
  37. package/docs/coverage/cyberia/projects/cyberia/instance-backup.js.html +715 -0
  38. package/docs/coverage/cyberia/projects/cyberia/instance-data.js.html +2182 -0
  39. package/docs/coverage/cyberia/projects/cyberia/shape-generator.js.html +3265 -0
  40. package/docs/coverage/cyberia/projects/cyberia/stat-balance.js.html +328 -0
  41. package/docs/coverage/cyberia/sort-arrow-sprite.png +0 -0
  42. package/docs/coverage/cyberia/sorter.js +205 -0
  43. package/hardhat/package-lock.json +2 -2
  44. package/hardhat/package.json +1 -1
  45. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  46. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +1 -1
  47. package/manifests/deployment/dd-cyberia-development/deployment.yaml +1 -1
  48. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  49. package/package.json +2 -2
  50. package/scripts/test-monitor.sh +1 -1
  51. package/src/cli/cluster.js +73 -18
  52. package/src/cli/deploy.js +211 -30
  53. package/src/cli/docker-compose.js +1 -1
  54. package/src/cli/release.js +3 -1
  55. package/src/cli/repository.js +7 -9
  56. package/src/cli/run.js +10 -3
  57. package/src/client/components/core/Badge.js +0 -1
  58. package/src/client/components/core/CssCore.js +8 -1
  59. package/src/client/components/core/Modal.js +29 -30
  60. package/src/client/components/core/ToolTip.js +15 -0
  61. package/src/client/components/cryptokoyn/AppShellCryptokoyn.js +0 -2
  62. package/src/client/components/cyberia-portal/RouterCyberiaPortal.js +0 -4
  63. package/src/client/components/default/AppShellDefault.js +1 -4
  64. package/src/client/components/itemledger/AppShellItemledger.js +0 -2
  65. package/src/client/components/underpost/AppShellUnderpost.js +1 -1
  66. package/src/client/public/cyberia-docs/CYBERIA.md +1 -1
  67. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  68. package/src/client/public/cyberia-docs/WHITE-PAPER.md +1 -1
  69. package/src/client/ssr/views/Cyberia404.js +3 -3
  70. package/src/index.js +1 -1
  71. package/src/runtime/engine-cyberia/Dockerfile +1 -1
  72. package/src/runtime/engine-cyberia/Dockerfile.dev +1 -1
  73. package/src/runtime/engine-cyberia/Dockerfile.test +1 -1
  74. package/src/runtime/engine-cyberia/docker-compose.yml +7 -7
  75. package/src/server/network/underpost-gateway.js +113 -14
  76. package/src/server/runtime/conf.js +36 -7
  77. package/src/server/runtime/start.js +11 -2
  78. package/test/integration/infra/4-ingress/underpost-ingress.test.js +1 -1
  79. package/test/unit/catalog.test.js +13 -2
  80. package/test/unit/conf-resolution.test.js +41 -7
  81. package/test/unit/cyberia/instance-data.test.js +314 -0
  82. package/test/unit/deploy-legacy-gateway-sweep.test.js +63 -0
  83. package/test/unit/gateway-pod-fetch.test.js +95 -0
  84. package/test/unit/letsencrypt-issuer.test.js +101 -0
  85. package/test/unit/release-bump.test.js +3 -1
  86. package/test/unit/start-options.test.js +12 -0
@@ -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
- if (!Underpost.kubectl.get('cert-manager').find((p) => p.STATUS === 'Running')) {
675
- shellExec(`helm repo add jetstack https://charts.jetstack.io --force-update`);
676
- shellExec(
677
- `helm install cert-manager jetstack/cert-manager \
678
- --namespace cert-manager \
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
- shellExec(`kubectl apply -f - -n ${namespace} <<'EOF'
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
- shellExec(`kubectl apply -f - -n ${namespace} <<'EOF'
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
- for (const host of Object.keys(confServer)) {
2345
- if (!options.disableUpdateProxy) {
2346
- // The host's route object is left in place and replaced by the `apply`
2347
- // below. Deleting it first unpublished the hostname for the whole
2348
- // reconciliation window, so every promote dropped live requests before
2349
- // the new colour was ever the question.
2350
- //
2351
- // A deploy that previously ran the per-host model left a Gateway
2352
- // named after each host. Those are superseded by the consolidated
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.76
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
@@ -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
- shellExec(`node bin/build ${buildTarget}`);
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`);
@@ -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 and reset to a pristine HEAD, so catalog `sourceMoves`
2164
- * can (re)pull custom sources even after a previous build moved them out of
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 restores a clean checkout
2168
- * (`git checkout .` brings back any moved-out tracked files) and pulls latest.
2169
- * Mirrors the sibling-repo handling used by `syncPrivateConf`.
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} && git checkout . && git clean -f -d && underpost pull . ${gitUri}`, {
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.76';
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((entry) => `${entry.direct}` === `${entry.gateway}`);
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) => /^[0-9]{3}$/.test(entry.direct) && `${entry.direct}` !== `${entry.gateway}`,
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 only opens the menu: it shows while the top bar is collapsed
419
- // and the menu is closed. An open menu (50px or full) carries its own controls and
420
- // already pushes the views aside, so neither the button nor the title padding apply.
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 active = collapsed && Modal.Data[idModal][options.mode].width === 0;
426
- fixed.classList[active ? 'remove' : 'add']('hide');
427
- s('body').classList[active ? 'add' : 'remove']('main-btn-menu-top-fixed-active');
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">${barConfig.buttons.menu.label}</div>`,
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-active .title-main-modal {
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
- const buildBadgeToolTipMenuOption = (id, sideKey = 'left') => {
2888
- const option = {
2889
- id: `tooltip-content-main-btn-${id}`,
2890
- text: `${Translate.instance(`${id}`)}`,
2891
- classList: 'tooltip-menu',
2892
- style: { top: `-40px` },
2893
- };
2894
- switch (sideKey) {
2895
- case 'left':
2896
- option.style.left = '60px';
2897
-
2898
- break;
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) {