@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
@@ -26,8 +26,6 @@ class AppShellCryptokoyn {
26
26
  const RouterInstance = RouterCryptokoyn.instance();
27
27
 
28
28
  const { barConfig } = await Themes[Css.currentTheme]();
29
-
30
- const badgeNotificationMenuStyle = { top: '-33px', left: '24px' };
31
29
  const barMode = undefined; // 'top-bottom-bar';
32
30
  await Modal.instance({
33
31
  id: 'modal-menu',
@@ -56,10 +56,6 @@ class RouterCyberiaPortal {
56
56
  title: 'cyberia-entity-engine',
57
57
  render: () => s(`.main-btn-cyberia-entity-engine`).click(),
58
58
  },
59
- '/fallback-world-engine': {
60
- title: 'fallback-world-engine',
61
- render: () => s(`.main-btn-fallback-world-engine`).click(),
62
- },
63
59
  '/instance-selection': {
64
60
  title: 'instance-selection',
65
61
  render: () => s(`.main-btn-instance-selection`).click(),
@@ -31,8 +31,6 @@ class AppShellDefault {
31
31
  const RouterInstance = RouterDefault.instance();
32
32
 
33
33
  const { barConfig } = await Themes[Css.currentTheme]();
34
-
35
- const badgeNotificationMenuStyle = { top: '-33px', left: '24px' };
36
34
  const barMode = undefined; // 'top-bottom-bar';
37
35
  await Modal.instance({
38
36
  id: 'modal-menu',
@@ -197,8 +195,7 @@ class AppShellDefault {
197
195
  ${await Badge.instance({
198
196
  id: 'main-btn-chat',
199
197
  type: 'circle-red',
200
- style: badgeNotificationMenuStyle,
201
- classList: 'hide',
198
+ classList: 'hide badge-notification-menu',
202
199
  })}`,
203
200
  attrs: `data-id="chat"`,
204
201
  tabHref: `${getProxyPath()}chat`,
@@ -27,8 +27,6 @@ class AppShellItemledger {
27
27
  const RouterInstance = RouterItemledger.instance();
28
28
 
29
29
  const { barConfig } = await Themes[Css.currentTheme]();
30
-
31
- const badgeNotificationMenuStyle = { top: '-33px', left: '24px' };
32
30
  const barMode = undefined; // 'top-bottom-bar';
33
31
  await Modal.instance({
34
32
  id: 'modal-menu',
@@ -227,7 +227,7 @@ class AppShellUnderpost {
227
227
  titleRender: () => {
228
228
  return '';
229
229
  },
230
- mode: 'slide-menu',
230
+ mode: 'slide-menu-right',
231
231
  RouterInstance,
232
232
  searchCustomImgClass: 'underpost-menu-icon',
233
233
  htmlMainBody: async () => {
@@ -20,7 +20,7 @@ _Stackable Rendering Layers as a Unified Tokenized Reality_
20
20
  <!-- CI/CD and health -->
21
21
  <div align="center">
22
22
 
23
- [![Node.js CI](https://img.shields.io/github/actions/workflow/status/underpostnet/engine-cyberia/docker-image.engine-cyberia.ci.yml?branch=main&label=Node.js%20CI)](https://github.com/underpostnet/engine-cyberia/actions/workflows/docker-image.engine-cyberia.ci.yml) [![Test](https://img.shields.io/github/actions/workflow/status/underpostnet/engine-cyberia/coverall.cyberia.ci.yml?branch=main&label=Test)](https://github.com/underpostnet/engine-cyberia/actions/workflows/coverall.cyberia.ci.yml) [![Coverage Status](https://coveralls.io/repos/github/underpostnet/engine-cyberia/badge.svg?branch=main)](https://coveralls.io/github/underpostnet/engine-cyberia?branch=main) [![Socket Badge](https://socket.dev/api/badge/npm/package/cyberia/3.3.76)](https://socket.dev/npm/package/cyberia/overview/3.3.76)
23
+ [![Node.js CI](https://img.shields.io/github/actions/workflow/status/underpostnet/engine-cyberia/docker-image.engine-cyberia.ci.yml?branch=main&label=Node.js%20CI)](https://github.com/underpostnet/engine-cyberia/actions/workflows/docker-image.engine-cyberia.ci.yml) [![Test](https://img.shields.io/github/actions/workflow/status/underpostnet/engine-cyberia/coverall.cyberia.ci.yml?branch=main&label=Test)](https://github.com/underpostnet/engine-cyberia/actions/workflows/coverall.cyberia.ci.yml) [![Coverage Status](https://coveralls.io/repos/github/underpostnet/engine-cyberia/badge.svg?branch=main)](https://coveralls.io/github/underpostnet/engine-cyberia?branch=main) [![Socket Badge](https://socket.dev/api/badge/npm/package/cyberia/3.3.77)](https://socket.dev/npm/package/cyberia/overview/3.3.77)
24
24
 
25
25
  </div>
26
26
 
@@ -1,6 +1,6 @@
1
1
  # Cyberia Online — Development Roadmap
2
2
 
3
- **Current version:** 3.3.76 | **Target milestone:** Open Alpha
3
+ **Current version:** 3.3.77 | **Target milestone:** Open Alpha
4
4
 
5
5
  ---
6
6
 
@@ -18,7 +18,7 @@ _Stackable Rendering Layers as a Unified Tokenized Reality_
18
18
 
19
19
  ---
20
20
 
21
- **Version:** 3.3.76 | **Status:** Draft | **Authors:** Underpost Engineering
21
+ **Version:** 3.3.77 | **Status:** Draft | **Authors:** Underpost Engineering
22
22
 
23
23
  ---
24
24
 
@@ -155,7 +155,7 @@ const main = () => {
155
155
  text-align: center;
156
156
  padding: 24px;
157
157
  }
158
- .badge {
158
+ .status-badge {
159
159
  display: inline-flex;
160
160
  align-items: center;
161
161
  gap: 8px;
@@ -168,7 +168,7 @@ const main = () => {
168
168
  margin-bottom: 26px;
169
169
  box-shadow: 0 0 12px rgba(34, 211, 238, 0.35);
170
170
  }
171
- .badge .dot {
171
+ .status-badge .dot {
172
172
  width: 8px;
173
173
  height: 8px;
174
174
  background: var(--green);
@@ -296,7 +296,7 @@ const main = () => {
296
296
  </style>
297
297
  <div class="grid"></div>
298
298
  <main class="wrap">
299
- <span class="badge"><span class="dot"></span> INSTANCE ${instanceLabel()}</span>
299
+ <span class="status-badge"><span class="dot"></span> INSTANCE ${instanceLabel()}</span>
300
300
  <div class="code" data-text="404">404</div>
301
301
  <div class="headline">SIGNAL LOST</div>
302
302
  <p class="sub">
package/src/index.js CHANGED
@@ -52,7 +52,7 @@ class Underpost {
52
52
  * @type {String}
53
53
  * @memberof Underpost
54
54
  */
55
- static version = 'v3.3.76';
55
+ static version = 'v3.3.77';
56
56
 
57
57
  /**
58
58
  * Required Node.js major version
@@ -6,7 +6,7 @@
6
6
  # Stage 1 — builder: clone the private deploy, build it, then scrub secrets.
7
7
  # ---------------------------------------------------------------------------
8
8
  FROM rockylinux/rockylinux:9 AS builder
9
- ARG UNDERPOST_VERSION=3.3.76
9
+ ARG UNDERPOST_VERSION=3.3.77
10
10
  # Pin Node to an exact patch: dnf's nodejs:24 module lags (24.14.1) while
11
11
  # underpost's dependencies require >=24.15.0, so install the official binary.
12
12
  ARG NODE_VERSION=24.15.0
@@ -6,7 +6,7 @@
6
6
  # Stage 1 — builder: clone the private deploy, build it, then scrub secrets.
7
7
  # ---------------------------------------------------------------------------
8
8
  FROM rockylinux/rockylinux:9 AS builder
9
- ARG UNDERPOST_VERSION=3.3.76
9
+ ARG UNDERPOST_VERSION=3.3.77
10
10
  # Pin Node to an exact patch: dnf's nodejs:24 module lags (24.14.1) while
11
11
  # underpost's dependencies require >=24.15.0, so install the official binary.
12
12
  ARG NODE_VERSION=24.15.0
@@ -6,7 +6,7 @@
6
6
  # Stage 1 — builder: clone the private deploy, build it, then scrub secrets.
7
7
  # ---------------------------------------------------------------------------
8
8
  FROM rockylinux/rockylinux:9 AS builder
9
- ARG UNDERPOST_VERSION=3.3.76
9
+ ARG UNDERPOST_VERSION=3.3.77
10
10
  # Pin Node to an exact patch: dnf's nodejs:24 module lags (24.14.1) while
11
11
  # underpost's dependencies require >=24.15.0, so install the official binary.
12
12
  ARG NODE_VERSION=24.15.0
@@ -135,7 +135,7 @@ services:
135
135
  # server starts but rejects connections until seeded). Serves REST/WS on 4005
136
136
  # and the engine gRPC data service on 50051.
137
137
  engine-cyberia-runtime:
138
- image: ${ENGINE_CYBERIA_IMAGE:-underpost/engine-cyberia-dev}:${ENGINE_CYBERIA_TAG:-v3.3.76}
138
+ image: ${ENGINE_CYBERIA_IMAGE:-underpost/engine-cyberia-dev}:${ENGINE_CYBERIA_TAG:-v3.3.77}
139
139
  container_name: dd-cyberia-engine
140
140
  command:
141
141
  - /bin/sh
@@ -190,7 +190,7 @@ services:
190
190
  # gateway on host port 8081, so one port can route every instance path.
191
191
 
192
192
  cyberia-server-runtime:
193
- image: ${CYBERIA_SERVER_IMAGE:-underpost/cyberia-server-dev}:${CYBERIA_SERVER_TAG:-v3.3.76}
193
+ image: ${CYBERIA_SERVER_IMAGE:-underpost/cyberia-server-dev}:${CYBERIA_SERVER_TAG:-v3.3.77}
194
194
  container_name: dd-cyberia-server
195
195
  environment:
196
196
  INSTANCE_CODE: amethyst-strata-expansion
@@ -228,7 +228,7 @@ services:
228
228
  restart: unless-stopped
229
229
 
230
230
  cyberia-server-forest:
231
- image: ${CYBERIA_SERVER_IMAGE:-underpost/cyberia-server-dev}:${CYBERIA_SERVER_TAG:-v3.3.76}
231
+ image: ${CYBERIA_SERVER_IMAGE:-underpost/cyberia-server-dev}:${CYBERIA_SERVER_TAG:-v3.3.77}
232
232
  container_name: dd-cyberia-server-forest
233
233
  environment:
234
234
  INSTANCE_CODE: FOREST
@@ -266,7 +266,7 @@ services:
266
266
  restart: unless-stopped
267
267
 
268
268
  cyberia-server-test:
269
- image: ${CYBERIA_SERVER_IMAGE:-underpost/cyberia-server-dev}:${CYBERIA_SERVER_TAG:-v3.3.76}
269
+ image: ${CYBERIA_SERVER_IMAGE:-underpost/cyberia-server-dev}:${CYBERIA_SERVER_TAG:-v3.3.77}
270
270
  container_name: dd-cyberia-server-test
271
271
  environment:
272
272
  INSTANCE_CODE: TEST
@@ -315,7 +315,7 @@ services:
315
315
  # cyberia-server-test -> cyberia-client-test
316
316
 
317
317
  cyberia-client-runtime:
318
- image: ${CYBERIA_CLIENT_IMAGE:-underpost/cyberia-client-dev}:${CYBERIA_CLIENT_TAG:-v3.3.76}
318
+ image: ${CYBERIA_CLIENT_IMAGE:-underpost/cyberia-client-dev}:${CYBERIA_CLIENT_TAG:-v3.3.77}
319
319
  container_name: dd-cyberia-client
320
320
  environment:
321
321
  CYBERIA_PORT: '8081'
@@ -340,7 +340,7 @@ services:
340
340
  restart: unless-stopped
341
341
 
342
342
  cyberia-client-forest:
343
- image: ${CYBERIA_CLIENT_IMAGE:-underpost/cyberia-client-dev}:${CYBERIA_CLIENT_TAG:-v3.3.76}
343
+ image: ${CYBERIA_CLIENT_IMAGE:-underpost/cyberia-client-dev}:${CYBERIA_CLIENT_TAG:-v3.3.77}
344
344
  container_name: dd-cyberia-client-forest
345
345
  environment:
346
346
  CYBERIA_PORT: '8081'
@@ -365,7 +365,7 @@ services:
365
365
  restart: unless-stopped
366
366
 
367
367
  cyberia-client-test:
368
- image: ${CYBERIA_CLIENT_IMAGE:-underpost/cyberia-client-dev}:${CYBERIA_CLIENT_TAG:-v3.3.76}
368
+ image: ${CYBERIA_CLIENT_IMAGE:-underpost/cyberia-client-dev}:${CYBERIA_CLIENT_TAG:-v3.3.77}
369
369
  container_name: dd-cyberia-client-test
370
370
  environment:
371
371
  CYBERIA_PORT: '8081'
@@ -957,6 +957,104 @@ const placeInstanceStaticAssets = ({ instances, options, label }) => {
957
957
  return assertStaticAssets({ records, hostRoot, label });
958
958
  };
959
959
 
960
+ /**
961
+ * @method parseRawHttpResponse
962
+ * @description Splits a raw HTTP/1.x response into its status code and body. The body
963
+ * is everything after the first blank line, byte for byte, so it can be hashed against
964
+ * the document on disk.
965
+ * @param {string} raw - Response as read off the socket.
966
+ * @returns {{status: string, body: string}} Status code (empty when no status line) and body.
967
+ * @memberof UnderpostGateway
968
+ */
969
+ const parseRawHttpResponse = (raw = '') => {
970
+ const status = /^HTTP\/[0-9.]+\s+([0-9]{3})/.exec(raw)?.[1] || '';
971
+ if (!status) return { status: '', body: '' };
972
+ const separator = raw.search(/\r?\n\r?\n/);
973
+ if (separator < 0) return { status, body: '' };
974
+ return { status, body: raw.slice(separator).replace(/^\r?\n\r?\n/, '') };
975
+ };
976
+
977
+ /**
978
+ * @method gatewayPodFetch
979
+ * @description One request against the gateway from inside its own pod, returning the
980
+ * status and the body even when the status is an error.
981
+ *
982
+ * Written on the socket with `nc` rather than fetched with `wget`: the image ships
983
+ * busybox, whose `wget` discards the body of any non-2xx response and exits — so a
984
+ * fallback document, which is by definition delivered with an upstream-failure status,
985
+ * could never be read through it. The probe that did so failed every attempt on a
986
+ * correct edge and looked like a hang. HTTP/1.0 keeps the reply unchunked, so the body
987
+ * is the document verbatim. Where `nc` is absent the `wget` pair still yields the status.
988
+ * @param {object} params
989
+ * @param {string} params.namespace - Namespace the gateway runs in.
990
+ * @param {string} params.host - Host header to present.
991
+ * @param {string} [params.path] - Request path.
992
+ * @returns {{status: string, body: string}} What the gateway answered.
993
+ * @memberof UnderpostGateway
994
+ */
995
+ const gatewayPodFetch = ({ namespace, host, path = '/' }) => {
996
+ const exec = (script) =>
997
+ `${
998
+ shellExec(`kubectl exec -n ${namespace} deploy/${UNDERPOST_GATEWAY.name} -- sh -c "${script}"`, {
999
+ stdout: true,
1000
+ silent: true,
1001
+ silentOnError: true,
1002
+ disableLog: true,
1003
+ }) ?? ''
1004
+ }`;
1005
+ const request = path || '/';
1006
+ const raw = exec(
1007
+ `printf 'GET ${request} HTTP/1.0\\r\\nHost: ${host}\\r\\n\\r\\n' | nc -w 10 127.0.0.1 ${UNDERPOST_GATEWAY.port} 2>/dev/null || true`,
1008
+ );
1009
+ const parsed = parseRawHttpResponse(raw);
1010
+ if (parsed.status) return parsed;
1011
+ const url = `http://127.0.0.1${request}`;
1012
+ const headers = exec(`wget -S -O /dev/null -T 10 --header 'Host: ${host}' ${url} 2>&1 || true`);
1013
+ const status = [...headers.matchAll(/HTTP\/[0-9.]+\s+([0-9]{3})/g)].pop()?.[1] || '';
1014
+ const body = exec(`wget -q -O - -T 10 --header 'Host: ${host}' ${url} 2>/dev/null || true`);
1015
+ return { status, body };
1016
+ };
1017
+
1018
+ /**
1019
+ * @method gatewayFetch
1020
+ * @description One request against the gateway, returning the status and the body
1021
+ * even when the status is an error — the shape every fallback probe asserts on.
1022
+ *
1023
+ * Sent from this host with `curl` to the gateway Service first: the sync runs on the
1024
+ * node that holds the gateway's volume, so the ClusterIP is reachable, and curl keeps
1025
+ * the body of an error response. Only when that yields no status line does it fall back
1026
+ * to {@link UnderpostGateway.gatewayPodFetch}, which needs nothing on the host at all.
1027
+ * @param {object} params
1028
+ * @param {string} params.namespace - Namespace the gateway runs in.
1029
+ * @param {string} params.host - Host header to present.
1030
+ * @param {string} [params.path] - Request path.
1031
+ * @returns {{status: string, body: string, via: string}} What the gateway answered, and which way.
1032
+ * @memberof UnderpostGateway
1033
+ */
1034
+ const gatewayFetch = ({ namespace, host, path = '/' }) => {
1035
+ const clusterIp = `${
1036
+ shellExec(`kubectl get svc ${UNDERPOST_GATEWAY.serviceName} -n ${namespace} -o jsonpath='{.spec.clusterIP}'`, {
1037
+ stdout: true,
1038
+ silent: true,
1039
+ silentOnError: true,
1040
+ disableLog: true,
1041
+ }) ?? ''
1042
+ }`.trim();
1043
+ if (/^[0-9a-f.:]+$/i.test(clusterIp)) {
1044
+ const raw = `${
1045
+ shellExec(
1046
+ `curl -sS -i --noproxy '*' --max-time 10 -H 'Host: ${host}' http://${clusterIp}:${UNDERPOST_GATEWAY.port}${
1047
+ path || '/'
1048
+ } 2>/dev/null || true`,
1049
+ { stdout: true, silent: true, silentOnError: true, disableLog: true },
1050
+ ) ?? ''
1051
+ }`;
1052
+ const parsed = parseRawHttpResponse(raw);
1053
+ if (parsed.status) return { ...parsed, via: 'host' };
1054
+ }
1055
+ return { ...gatewayPodFetch({ namespace, host, path }), via: 'pod' };
1056
+ };
1057
+
960
1058
  /**
961
1059
  * @method gatewayFallbackProbeRunner
962
1060
  * @description Proves the edge answers each configured fallback with the exact
@@ -1018,26 +1116,24 @@ const gatewayFallbackProbeRunner = async ({ checks, options, label, gatewayStatu
1018
1116
  silent: true,
1019
1117
  silentOnError: true,
1020
1118
  }).trim();
1021
- } else {
1022
- const request = `http://127.0.0.1${check.path || '/'}`;
1023
- body = shellExec(
1024
- `kubectl exec -n ${namespace} deploy/${UNDERPOST_GATEWAY.name} -- sh -c ` +
1025
- `"wget -q -O - -T 10 --header 'Host: ${check.host}' ${request} 2>/dev/null || true"`,
1026
- { stdout: true, silent: true, silentOnError: true },
1027
- );
1028
- const headers = shellExec(
1029
- `kubectl exec -n ${namespace} deploy/${UNDERPOST_GATEWAY.name} -- sh -c ` +
1030
- `"wget -S -O /dev/null -T 10 --header 'Host: ${check.host}' ${request} 2>&1 || true"`,
1031
- { stdout: true, silent: true, silentOnError: true },
1032
- );
1033
- status = [...headers.matchAll(/HTTP\/[0-9.]+\s+([0-9]{3})/g)].pop()?.[1] || '';
1034
- }
1119
+ } else ({ status, body } = gatewayFetch({ namespace, host: check.host, path: check.path }));
1035
1120
  actualHash = crypto
1036
1121
  .createHash('sha256')
1037
1122
  .update(body || '')
1038
1123
  .digest('hex');
1039
1124
  passed = /^50[234]$/.test(status) && !!expectedHash && actualHash === expectedHash;
1040
1125
  if (passed) break;
1126
+ // The first miss is reported as it happens: a probe that polls for minutes with
1127
+ // nothing on the console reads as a hang, and what it saw is the whole diagnosis.
1128
+ if (attempts === 1)
1129
+ logger.warn(`[${label}] Fallback probe not yet satisfied; polling`, {
1130
+ host: check.host,
1131
+ path: check.path || '/',
1132
+ status: status || '(none)',
1133
+ bodyBytes: Buffer.byteLength(body || ''),
1134
+ bodyMatchesConfiguredAsset: actualHash === expectedHash,
1135
+ expectedAsset: expectedHash ? check.assetPath : '(missing on disk)',
1136
+ });
1041
1137
  if (attempts < 30) await timer(2000);
1042
1138
  }
1043
1139
  const result = {
@@ -1059,6 +1155,8 @@ export {
1059
1155
  UNDERPOST_GATEWAY,
1060
1156
  assertStaticAssets,
1061
1157
  gatewayFallbackProbeRunner,
1158
+ gatewayFetch,
1159
+ gatewayPodFetch,
1062
1160
  gatewayStaticAssetExists,
1063
1161
  hostInstanceRegistryPathFactory,
1064
1162
  hostServerConfFactory,
@@ -1071,6 +1169,7 @@ export {
1071
1169
  kubernetesUpstreamFactory,
1072
1170
  underpostGatewayManifestsFactory,
1073
1171
  nginxConfFactory,
1172
+ parseRawHttpResponse,
1074
1173
  seedDefaultStatusPage,
1075
1174
  staticLocationFactory,
1076
1175
  staticPathSegmentFactory,
@@ -3001,14 +3001,41 @@ const syncPrivateConf = (deployId, extraPaths = []) => {
3001
3001
  };
3002
3002
 
3003
3003
  /**
3004
- * Moves a deploy's public template sources into the engine working tree ahead of
3005
- * the build copy step. Idempotent and safe to rerun: each move is guarded by
3006
- * `existsSync`, so already-moved or absent sources are skipped rather than throwing.
3007
- * The `[src, dest]` pairs come from the deploy's product catalog (passed in), so
3008
- * this module stays product-agnostic.
3004
+ * Mirrors a deploy's public template sources from the engine working tree to the source
3005
+ * repo they came from. The engine ignores every one of these paths, so an edit made there
3006
+ * never reaches a diff; the source repo is where it is reviewed and committed. Each path
3007
+ * the engine holds is replaced wholesale there (removed, then copied) so the source repo's
3008
+ * `git status` reads as exactly the engine tree's state, deletions included. The engine
3009
+ * copies stay in place: this is a copy, not a move. A path the engine tree does not hold
3010
+ * is left untouched in the source repo rather than deleted.
3011
+ *
3012
+ * @method syncDeployIdSourcesBack
3013
+ * @param {Array<[string, string]>} [sourceMoves=[]] - Public `[src, dest]` pairs.
3014
+ * @returns {string[]} The source paths that were mirrored.
3015
+ * @memberof ServerConfBuilder
3016
+ */
3017
+ const syncDeployIdSourcesBack = (sourceMoves = []) => {
3018
+ const mirrored = [];
3019
+ for (const [src, dest] of sourceMoves) {
3020
+ if (!fs.existsSync(dest)) continue;
3021
+ fs.removeSync(src);
3022
+ fs.copySync(dest, src);
3023
+ mirrored.push(src);
3024
+ }
3025
+ return mirrored;
3026
+ };
3027
+
3028
+ /**
3029
+ * Brings a deploy's public template sources into line ahead of the build copy step. The
3030
+ * engine working tree is the working copy and the source repo its git view, so a path the
3031
+ * engine already holds is mirrored out to the repo ({@link syncDeployIdSourcesBack}) and
3032
+ * only a path the engine lacks is copied in. Nothing is moved: the repo's working tree
3033
+ * keeps every file, so a build never leaves it reading as wholesale deletions, and an
3034
+ * engine-side edit is never overwritten by the repo's copy. Safe to rerun: each step is
3035
+ * guarded by `existsSync`.
3009
3036
  *
3010
3037
  * @method syncDeployIdSources
3011
- * @param {Array<[string, string]>} [sourceMoves=[]] - Public `[src, dest]` move pairs.
3038
+ * @param {Array<[string, string]>} [sourceMoves=[]] - Public `[src, dest]` pairs.
3012
3039
  * @returns {boolean} `true` when any sources were declared, else `false`.
3013
3040
  * @memberof ServerConfBuilder
3014
3041
  */
@@ -3016,7 +3043,8 @@ const syncDeployIdSources = (sourceMoves = []) => {
3016
3043
  if (!sourceMoves.length) return false;
3017
3044
  for (const dir of ['src/api', 'src/client/components', 'src/client/public', 'src/client/services'])
3018
3045
  fs.mkdirSync(dir, { recursive: true });
3019
- for (const [src, dest] of sourceMoves) if (fs.existsSync(src)) fs.moveSync(src, dest, { overwrite: true });
3046
+ syncDeployIdSourcesBack(sourceMoves);
3047
+ for (const [src, dest] of sourceMoves) if (!fs.existsSync(dest) && fs.existsSync(src)) fs.copySync(src, dest);
3020
3048
  return true;
3021
3049
  };
3022
3050
 
@@ -3448,6 +3476,7 @@ export {
3448
3476
  waitForPort,
3449
3477
  syncPrivateConf,
3450
3478
  syncDeployIdSources,
3479
+ syncDeployIdSourcesBack,
3451
3480
  gitOriginRepositoryName,
3452
3481
  ensureTemplateCheckout,
3453
3482
  pruneTemplateWorkTree,
@@ -333,10 +333,19 @@ class UnderpostStartUp {
333
333
  const result = await awaitDeployMonitor(true);
334
334
  if (result === true) {
335
335
  // Withdraw every domain's local traces once the deployment is serving. Three calls
336
- // rather than one cross-domain sweep: each domain owns what it put on disk.
336
+ // rather than one cross-domain sweep: each domain owns what it put on disk. Only the
337
+ // host domain is forced: its `--force` removes the ephemeral `engine-private` clone,
338
+ // which must not outlive the boot inside a container. For the other two, `--force`
339
+ // reaches into the cluster (the app Secret, secret purges) and is never wanted here.
337
340
  if (env === 'production' && Underpost.state.isInsideContainer())
338
341
  for (const domain of [Underpost.secret, Underpost.host, Underpost.app])
339
- domain.clean({ env, namespace: 'default', args: {}, dryRun: false, force: false });
342
+ domain.clean({
343
+ env,
344
+ namespace: 'default',
345
+ args: {},
346
+ dryRun: false,
347
+ force: domain === Underpost.host,
348
+ });
340
349
  setTimeout(() => {
341
350
  setRuntimeStatus(deployId, env, RUNTIME_STATUS.RUNNING);
342
351
  setStartContainerStatus(deployId, env);
@@ -263,7 +263,7 @@ describe('shared ingress front', () => {
263
263
  );
264
264
  const testCandidate = install.indexOf("execIngress('nginx -t -c /tmp/nginx.candidate.conf'");
265
265
  const reload = install.indexOf('nginx -s reload -c /tmp/nginx.conf', testCandidate);
266
- const persist = install.indexOf("shellExec(`kubectl apply -f - -n ${namespace} <<'EOF'", reload);
266
+ const persist = install.indexOf("`kubectl apply -f - -n ${namespace} <<'EOF'", reload);
267
267
  expect(testCandidate).to.be.greaterThan(-1);
268
268
  expect(reload).to.be.greaterThan(testCandidate);
269
269
  expect(persist).to.be.greaterThan(reload);
@@ -14,6 +14,12 @@ import { TEST_TIERS } from '../../src/server/build/testing.js';
14
14
  const catalogs = await loadProductCatalogs();
15
15
  const describeProducts = describe.skipIf(catalogs.length === 0);
16
16
 
17
+ // A product template keeps its own catalog but has every other product sliced away, so only
18
+ // the unsliced engine tree carries every catalog and every tier directory. `buildTemplate`
19
+ // drops `build:template` from the manifest and every product manifest inherits that, which
20
+ // makes the script the one durable marker of the unsliced tree.
21
+ const unsliced = !!JSON.parse(fs.readFileSync('./package.json', 'utf8')).scripts?.['build:template'];
22
+
17
23
  describeProducts('product catalogs', () => {
18
24
  it('every catalog carries the uniform shape', () => {
19
25
  for (const catalog of catalogs)
@@ -54,14 +60,19 @@ describeProducts('product catalogs and the test tiers', () => {
54
60
  catalogs.some(({ stripPaths }) => stripPaths.includes(`./${directory}`)),
55
61
  );
56
62
 
57
- it('has products that own at least one tier', () => {
63
+ it.skipIf(!unsliced)('has products that own at least one tier', () => {
58
64
  expect(strippedTiers).to.not.be.empty;
59
65
  });
60
66
 
61
- it('gives every tier a directory that exists in an unsliced tree', () => {
67
+ it.skipIf(!unsliced)('gives every tier a directory that exists in an unsliced tree', () => {
62
68
  for (const { name, directory } of TEST_TIERS) expect(fs.existsSync(directory), name).to.equal(true);
63
69
  });
64
70
 
71
+ it('carries the directory of every tier a present product owns', () => {
72
+ // Holds in a product template too: what it kept of itself must be whole.
73
+ for (const { name, directory } of strippedTiers) expect(fs.existsSync(directory), name).to.equal(true);
74
+ });
75
+
65
76
  it('ships every tier it strips from the base template', () => {
66
77
  for (const { name, directory } of strippedTiers) {
67
78
  const owner = catalogs.find(({ stripPaths }) => stripPaths.includes(`./${directory}`));
@@ -35,6 +35,7 @@ import {
35
35
  resolveHostKeyContext,
36
36
  resolveReplicaCount,
37
37
  syncDeployIdSources,
38
+ syncDeployIdSourcesBack,
38
39
  syncPrivateConf,
39
40
  updatePrivateEngineTestRepo,
40
41
  updatePrivateTemplateRepo,
@@ -757,17 +758,50 @@ describe('private conf sync', () => {
757
758
  expect(syncDeployIdSources([])).to.equal(false);
758
759
  });
759
760
 
760
- it('moves every declared source that is present, skipping the rest', () => {
761
- const moved = [];
762
- vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => `${filePath}`.includes('present'));
763
- vi.spyOn(fs, 'moveSync').mockImplementation((src, dest) => moved.push(`${src} -> ${dest}`));
761
+ it('copies in only the sources the engine tree lacks, and never moves', () => {
762
+ // Regression: the repo copy used to be moved over the engine copy, so a build discarded
763
+ // every edit made in the engine tree and left the repo reading as wholesale deletions.
764
+ const calls = [];
765
+ const present = new Set(['../engine-x/src/new.js', '../engine-x/src/edited.js', 'src/api/edited.js']);
766
+ vi.spyOn(fs, 'mkdirSync').mockImplementation(() => {});
767
+ vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => present.has(`${filePath}`));
768
+ vi.spyOn(fs, 'removeSync').mockImplementation((target) => calls.push(`remove ${target}`));
769
+ vi.spyOn(fs, 'copySync').mockImplementation((src, dest) => calls.push(`copy ${src} -> ${dest}`));
770
+ vi.spyOn(fs, 'moveSync').mockImplementation(() => calls.push('move'));
764
771
  expect(
765
772
  syncDeployIdSources([
766
- ['src/present.js', 'src/api/present.js'],
767
- ['src/absent.js', 'src/api/absent.js'],
773
+ ['../engine-x/src/new.js', 'src/api/new.js'],
774
+ ['../engine-x/src/edited.js', 'src/api/edited.js'],
775
+ ['../engine-x/src/absent.js', 'src/api/absent.js'],
768
776
  ]),
769
777
  ).to.equal(true);
770
- expect(moved).to.deep.equal(['src/present.js -> src/api/present.js']);
778
+ expect(calls).to.deep.equal([
779
+ // The engine copy wins: it is mirrored out before anything is copied in.
780
+ 'remove ../engine-x/src/edited.js',
781
+ 'copy src/api/edited.js -> ../engine-x/src/edited.js',
782
+ 'copy ../engine-x/src/new.js -> src/api/new.js',
783
+ ]);
784
+ });
785
+
786
+ it('mirrors every source the tree holds back to its repo, replacing it whole', () => {
787
+ // The engine ignores the moved-in paths, so the source repo is the only place a change to
788
+ // them is diffed; replacing rather than overlaying makes deletions reach that diff too.
789
+ const calls = [];
790
+ vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => `${filePath}`.includes('present'));
791
+ vi.spyOn(fs, 'removeSync').mockImplementation((target) => calls.push(`remove ${target}`));
792
+ vi.spyOn(fs, 'copySync').mockImplementation((src, dest) => calls.push(`copy ${src} -> ${dest}`));
793
+ vi.spyOn(fs, 'moveSync').mockImplementation(() => calls.push('move'));
794
+ expect(
795
+ syncDeployIdSourcesBack([
796
+ ['../engine-x/src/present.js', 'src/api/present.js'],
797
+ ['../engine-x/src/absent.js', 'src/api/absent.js'],
798
+ ]),
799
+ ).to.deep.equal(['../engine-x/src/present.js']);
800
+ expect(calls).to.deep.equal([
801
+ 'remove ../engine-x/src/present.js',
802
+ 'copy src/api/present.js -> ../engine-x/src/present.js',
803
+ ]);
804
+ expect(syncDeployIdSourcesBack()).to.deep.equal([]);
771
805
  });
772
806
  });
773
807