underpost 3.2.70 → 3.2.80

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 (38) hide show
  1. package/.github/workflows/publish.ci.yml +3 -3
  2. package/.github/workflows/release.cd.yml +1 -1
  3. package/CHANGELOG.md +1177 -1038
  4. package/CLI-HELP.md +3 -1
  5. package/README.md +3 -3
  6. package/bin/build.js +10 -4
  7. package/docker-compose.yml +1 -1
  8. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  9. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  10. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  11. package/package.json +10 -10
  12. package/scripts/test-monitor.sh +1 -1
  13. package/src/api/core/core.controller.js +4 -65
  14. package/src/api/core/core.router.js +8 -14
  15. package/src/api/default/default.controller.js +2 -70
  16. package/src/api/default/default.router.js +7 -17
  17. package/src/api/document/document.controller.js +5 -77
  18. package/src/api/document/document.router.js +9 -13
  19. package/src/api/file/file.controller.js +9 -53
  20. package/src/api/file/file.router.js +14 -6
  21. package/src/api/test/test.controller.js +8 -53
  22. package/src/api/test/test.router.js +1 -4
  23. package/src/cli/cluster.js +31 -11
  24. package/src/cli/db.js +4 -2
  25. package/src/cli/deploy.js +51 -9
  26. package/src/cli/docker-compose.js +163 -9
  27. package/src/cli/fs.js +0 -1
  28. package/src/cli/image.js +25 -7
  29. package/src/cli/index.js +5 -0
  30. package/src/cli/release.js +4 -0
  31. package/src/cli/repository.js +13 -2
  32. package/src/cli/run.js +151 -78
  33. package/src/cli/ssh.js +30 -11
  34. package/src/client/components/core/Modal.js +38 -4
  35. package/src/index.js +1 -1
  36. package/src/server/conf.js +163 -0
  37. package/src/server/downloader.js +3 -3
  38. package/src/server/middlewares.js +152 -0
@@ -1,12 +1,10 @@
1
+ import express from 'express';
1
2
  import { adminGuard } from '../../server/auth.js';
2
- import { loggerFactory } from '../../server/logger.js';
3
3
  import { FileController } from './file.controller.js';
4
- import express from 'express';
5
-
6
- const logger = loggerFactory(import.meta);
7
4
 
8
5
  class FileRouter {
9
6
  /**
7
+ * authenticated writes, admin-only deletes.
10
8
  * @param {import('../types.js').RouterOptions} options
11
9
  * @returns {import('express').Router}
12
10
  */
@@ -17,8 +15,18 @@ class FileRouter {
17
15
  router.get(`/blob/:id`, async (req, res) => await FileController.get(req, res, options));
18
16
  router.get(`/:id`, async (req, res) => await FileController.get(req, res, options));
19
17
  router.get(`/`, async (req, res) => await FileController.get(req, res, options));
20
- router.delete(`/:id`, options.authMiddleware, adminGuard, async (req, res) => await FileController.delete(req, res, options));
21
- router.delete(`/`, options.authMiddleware, adminGuard, async (req, res) => await FileController.delete(req, res, options));
18
+ router.delete(
19
+ `/:id`,
20
+ options.authMiddleware,
21
+ adminGuard,
22
+ async (req, res) => await FileController.delete(req, res, options),
23
+ );
24
+ router.delete(
25
+ `/`,
26
+ options.authMiddleware,
27
+ adminGuard,
28
+ async (req, res) => await FileController.delete(req, res, options),
29
+ );
22
30
  return router;
23
31
  }
24
32
  }
@@ -1,59 +1,14 @@
1
- import { loggerFactory } from '../../server/logger.js';
1
+ import { controllerHandler, sendSuccess, serviceHandler } from '../../server/middlewares.js';
2
2
  import { TestService } from './test.service.js';
3
3
 
4
- const logger = loggerFactory(import.meta);
5
-
6
4
  class TestController {
7
- static post = async (req, res, options) => {
8
- try {
9
- return res.status(200).json({
10
- status: 'success',
11
- data: await TestService.post(req, res, options),
12
- });
13
- } catch (error) {
14
- logger.error(error, error.stack);
15
- return res.status(400).json({
16
- status: 'error',
17
- message: error.message,
18
- });
19
- }
20
- };
21
- static get = async (req, res, options) => {
22
- try {
23
- const result = await TestService.get(req, res, options);
24
- if (result)
25
- return res.status(200).json({
26
- status: 'success',
27
- data: result,
28
- });
29
- else
30
- return res.status(400).json({
31
- status: 'error',
32
- });
33
- } catch (error) {
34
- logger.error(error, error.stack);
35
- return res.status(400).json({
36
- status: 'error',
37
- message: error.message,
38
- });
39
- }
40
- };
41
- static delete = async (req, res, options) => {
42
- try {
43
- const result = await TestService.delete(req, res, options);
44
-
45
- return res.status(200).json({
46
- status: 'success',
47
- data: result,
48
- });
49
- } catch (error) {
50
- logger.error(error, error.stack);
51
- return res.status(400).json({
52
- status: 'error',
53
- message: error.message,
54
- });
55
- }
56
- };
5
+ static post = serviceHandler(TestService.post);
6
+ static get = controllerHandler(async (req, res, options) => {
7
+ const result = await TestService.get(req, res, options);
8
+ if (result) return sendSuccess(res, result);
9
+ return res.status(400).json({ status: 'error' });
10
+ });
11
+ static delete = serviceHandler(TestService.delete);
57
12
  }
58
13
 
59
14
  export { TestController };
@@ -1,8 +1,5 @@
1
- import { loggerFactory } from '../../server/logger.js';
2
- import { TestController } from './test.controller.js';
3
1
  import express from 'express';
4
-
5
- const logger = loggerFactory(import.meta);
2
+ import { TestController } from './test.controller.js';
6
3
 
7
4
  class TestRouter {
8
5
  /**
@@ -902,10 +902,9 @@ net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-k3s.conf > /dev/null`,
902
902
  { silentOnError: true },
903
903
  );
904
904
  // Podman native — on this host images/overlays live under /var/lib/containers/storage.
905
- shellExec(
906
- `if command -v podman >/dev/null 2>&1; then sudo podman system prune ${a}--volumes -f; fi`,
907
- { silentOnError: true },
908
- );
905
+ shellExec(`if command -v podman >/dev/null 2>&1; then sudo podman system prune ${a}--volumes -f; fi`, {
906
+ silentOnError: true,
907
+ });
909
908
  if (options.crictl) {
910
909
  const ep = options.criSocket ? `--runtime-endpoint ${options.criSocket} ` : '';
911
910
  shellExec(
@@ -1198,13 +1197,34 @@ exclude=kubelet kubeadm kubectl cri-tools kubernetes-cni
1198
1197
  EOF`);
1199
1198
  shellExec(`sudo yum install -y kubelet kubeadm kubectl --disableexcludes=kubernetes`);
1200
1199
 
1201
- // Install Helm
1202
- shellExec(`curl -fsSL -o get_helm.sh https://cdn.jsdelivr.net/gh/helm/helm@main/scripts/get-helm-3`);
1203
- shellExec(`chmod 700 get_helm.sh`);
1204
- shellExec(`./get_helm.sh`);
1205
- shellExec(`chmod +x /usr/local/bin/helm`);
1206
- shellExec(`sudo mv /usr/local/bin/helm /bin/helm`);
1207
- shellExec(`sudo rm -rf get_helm.sh`);
1200
+ // Install Helm — check if already installed and linked to /bin/helm first
1201
+ const helmBin = shellExec(`test -x /bin/helm && echo exists || echo missing`, {
1202
+ stdout: true,
1203
+ silent: true,
1204
+ }).trim();
1205
+ const helmLocalBin = shellExec(`test -x /usr/local/bin/helm && echo exists || echo missing`, {
1206
+ stdout: true,
1207
+ silent: true,
1208
+ }).trim();
1209
+
1210
+ if (helmBin === 'exists') {
1211
+ logger.info('Helm is already installed and linked to /bin/helm; skipping.');
1212
+ } else if (helmLocalBin === 'exists') {
1213
+ // Helm binary exists at /usr/local/bin but not linked to /bin/helm
1214
+ logger.info('Helm found at /usr/local/bin; linking to /bin/helm...');
1215
+ shellExec(`sudo ln -sf /usr/local/bin/helm /bin/helm`);
1216
+ } else {
1217
+ // Helm not installed — download and install
1218
+ shellExec(`curl -fsSL -o get_helm.sh https://cdn.jsdelivr.net/gh/helm/helm@main/scripts/get-helm-3`);
1219
+ shellExec(`chmod 700 get_helm.sh`);
1220
+ // Run get_helm.sh but ignore its exit code — it may fail on PATH check
1221
+ // even when installation succeeds (binary placed at /usr/local/bin).
1222
+ shellExec(`bash ./get_helm.sh || true`);
1223
+ // Ensure the binary is executable and linked to /bin/helm
1224
+ shellExec(`test -x /usr/local/bin/helm && sudo chmod +x /usr/local/bin/helm || true`);
1225
+ shellExec(`test -x /usr/local/bin/helm && sudo ln -sf /usr/local/bin/helm /bin/helm || true`);
1226
+ shellExec(`sudo rm -rf get_helm.sh`);
1227
+ }
1208
1228
 
1209
1229
  // Install snap
1210
1230
  shellExec(`sudo yum install -y snapd`);
package/src/cli/db.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * Supports MariaDB and MongoDB with import/export capabilities, Git integration, and multi-pod operations.
7
7
  */
8
8
 
9
- import { mergeFile, splitFileFactory, loadConfServerJson, resolveConfSecrets } from '../server/conf.js';
9
+ import { mergeFile, splitFileFactory, loadConfServerJson, resolveConfSecrets, loadConfInstances } from '../server/conf.js';
10
10
  import { loggerFactory } from '../server/logger.js';
11
11
  import { shellExec } from '../server/process.js';
12
12
  import fs from 'fs-extra';
@@ -1119,7 +1119,9 @@ class UnderpostDB {
1119
1119
  // Process additional instances
1120
1120
  const confInstancesPath = `./engine-private/conf/${deployId}/conf.instances.json`;
1121
1121
  if (fs.existsSync(confInstancesPath)) {
1122
- const confInstances = JSON.parse(fs.readFileSync(confInstancesPath, 'utf8'));
1122
+ // Expands multiInstance variants so each deployed instance is
1123
+ // registered (handles both the bare-array and { instances } shapes).
1124
+ const confInstances = loadConfInstances(deployId);
1123
1125
  for (const instance of confInstances) {
1124
1126
  const { id, host, path, fromPort, metadata } = instance;
1125
1127
  const { runtime } = metadata;
package/src/cli/deploy.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  cronDeployIdResolve,
13
13
  deployRangePortFactory,
14
14
  getDataDeploy,
15
+ loadConfInstances,
15
16
  loadConfServerJson,
16
17
  loadReplicas,
17
18
  pathPortAssignmentFactory,
@@ -22,8 +23,25 @@ import { INTERNAL_READY_PATH, INTERNAL_HEALTH_PATH } from '../server/runtime-sta
22
23
  import fs from 'fs-extra';
23
24
  import dotenv from 'dotenv';
24
25
  import os from 'node:os';
26
+ import crypto from 'node:crypto';
25
27
  import Underpost from '../index.js';
26
28
 
29
+ /**
30
+ * Clamps an identifier to the Kubernetes DNS-1123 label limit (63 chars),
31
+ * used for pod-local `volumes[].name` / `volumeMounts[].name`. Names within the
32
+ * limit are returned verbatim so existing short names are stable; longer ones
33
+ * are truncated and suffixed with an 8-char content hash to stay unique and
34
+ * deterministic (e.g. the per-variant instance volume names, which append the
35
+ * full `<deployId>-<env>-<traffic>` and can exceed 63).
36
+ * @param {string} name - Candidate name.
37
+ * @returns {string} A name no longer than 63 characters.
38
+ */
39
+ const k8sVolumeName = (name) => {
40
+ if (typeof name !== 'string' || name.length <= 63) return name;
41
+ const hash = crypto.createHash('sha1').update(name).digest('hex').slice(0, 8);
42
+ return `${name.slice(0, 54)}-${hash}`;
43
+ };
44
+
27
45
  const logger = loggerFactory(import.meta);
28
46
 
29
47
  /**
@@ -668,7 +686,7 @@ spec:
668
686
  * @returns {string|null} - Current traffic status ('blue' or 'green') or null if not found.
669
687
  * @memberof UnderpostDeploy
670
688
  */
671
- getCurrentTraffic(deployId, options = { hostTest: '', namespace: '' }) {
689
+ getCurrentTraffic(deployId, options = { hostTest: '', namespace: '', env: '' }) {
672
690
  if (!options.namespace) options.namespace = 'default';
673
691
  // kubectl get deploy,sts,svc,configmap,secret -n default -o yaml --export > default.yaml
674
692
  const hostTest = options?.hostTest
@@ -683,6 +701,18 @@ spec:
683
701
  silentOnError: true,
684
702
  });
685
703
  if (!info) return null;
704
+ // Env-scoped resolution: read THIS deploy's colour from its own service
705
+ // name (`<deployId>-<env>-<colour>-service`). Essential for shared
706
+ // multi-instance hosts, where one HTTPProxy holds several variants' routes
707
+ // on possibly different colours — a whole-document `.match('blue')` would
708
+ // return whichever colour appears first, i.e. a sibling's, not this one's.
709
+ // The regex is anchored on the full `<deployId>-<env>-` prefix so
710
+ // `dd-cyberia-mmo-server` never matches `dd-cyberia-mmo-server-forest`.
711
+ if (options.env) {
712
+ const escaped = deployId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
713
+ const match = info.match(new RegExp(`${escaped}-${options.env}-(blue|green)-service`));
714
+ return match ? match[1] : null;
715
+ }
686
716
  return info.match('blue') ? 'blue' : info.match('green') ? 'green' : null;
687
717
  },
688
718
 
@@ -825,9 +855,9 @@ EOF`);
825
855
  const deployId = _deployId.trim();
826
856
  const instances = [];
827
857
  if (fs.existsSync(`./engine-private/conf/${deployId}/conf.instances.json`)) {
828
- const confInstances = JSON.parse(
829
- fs.readFileSync(`./engine-private/conf/${deployId}/conf.instances.json`, 'utf8'),
830
- );
858
+ // Expands multiInstance variants so status lists every deployed
859
+ // instance (mmo-server, mmo-server-forest, …), not just the templates.
860
+ const confInstances = loadConfInstances(deployId);
831
861
  for (const instance of confInstances) {
832
862
  const _deployId = `${deployId}-${instance.id}`;
833
863
  instances.push({
@@ -838,7 +868,7 @@ EOF`);
838
868
  toPort: instance.toPort,
839
869
  fromDebugPort: instance.fromDebugPort,
840
870
  toDebugPort: instance.toDebugPort,
841
- traffic: Underpost.deploy.getCurrentTraffic(_deployId, { namespace, hostTest: instance.host }),
871
+ traffic: Underpost.deploy.getCurrentTraffic(_deployId, { namespace, hostTest: instance.host, env }),
842
872
  });
843
873
  }
844
874
  }
@@ -1075,7 +1105,13 @@ EOF`);
1075
1105
  * runners derive it from the comma-path field or `--node-name`
1076
1106
  * (`run sync`: `path.split(',')[4]` > `--node-name` > default) and from
1077
1107
  * `--node-name` directly (`run instance`).
1078
- * 2. **Cluster-type default** — when no explicit node is given: `kind-worker`
1108
+ * 2. **`UNDERPOST_DEPLOY_NODE` env** — for kubeadm / k3s, the configured
1109
+ * target node name. This makes hostPath PV `nodeAffinity` deterministic
1110
+ * regardless of where the manifest is *built*: building inside a
1111
+ * container or CI runner would otherwise leak that box's `os.hostname()`
1112
+ * (e.g. a random container id) into `nodeSelector`, pinning the PV to a
1113
+ * node that does not exist in the cluster.
1114
+ * 3. **Cluster-type default** — when nothing above is set: `kind-worker`
1079
1115
  * for a kind cluster (the node that hosts kind hostPath volumes),
1080
1116
  * otherwise the control-plane / current host (`os.hostname()`) for
1081
1117
  * kubeadm / k3s. With no explicit cluster flag, `development` is treated
@@ -1093,7 +1129,8 @@ EOF`);
1093
1129
  resolveDeployNode({ node = '', kind = false, kubeadm = false, k3s = false, env = '' } = {}) {
1094
1130
  if (node) return node;
1095
1131
  const isKind = kind || (!kubeadm && !k3s && env !== 'production');
1096
- return isKind ? 'kind-worker' : os.hostname();
1132
+ if (isKind) return 'kind-worker';
1133
+ return process.env.UNDERPOST_DEPLOY_NODE || os.hostname();
1097
1134
  },
1098
1135
 
1099
1136
  /**
@@ -1241,13 +1278,18 @@ EOF
1241
1278
  volumeName = `${volumeName}-${version}`;
1242
1279
  claimName = claimName ? `${claimName}-${version}` : null;
1243
1280
  }
1281
+ // The pod-local volume name is a DNS-1123 label (max 63 chars); the PVC
1282
+ // `claimName` it references is a subdomain (max 253) and stays verbatim.
1283
+ // Per-variant instance names append <deployId>-<env>-<traffic> and can
1284
+ // exceed 63, so clamp only the pod-local name (mount name must match it).
1285
+ const podVolumeName = k8sVolumeName(volumeName);
1244
1286
  _volumeMounts += `
1245
- - name: ${volumeName}
1287
+ - name: ${podVolumeName}
1246
1288
  mountPath: ${volumeMountPath}
1247
1289
  ${secret ? ` readOnly: true\n` : ''}`;
1248
1290
 
1249
1291
  _volumes += `
1250
- - name: ${volumeName}
1292
+ - name: ${podVolumeName}
1251
1293
  ${
1252
1294
  emptyDir
1253
1295
  ? ` emptyDir: {}`
@@ -12,6 +12,7 @@ import fs from 'fs-extra';
12
12
  import nodePath from 'path';
13
13
  import { getRootDirectory, shellExec } from '../server/process.js';
14
14
  import { loggerFactory } from '../server/logger.js';
15
+ import { loadInstanceTopology } from '../server/conf.js';
15
16
  import Nginx from '../runtime/nginx/Nginx.js';
16
17
 
17
18
  const logger = loggerFactory(import.meta);
@@ -191,7 +192,7 @@ services:
191
192
  MONGO_IMAGE=mongo:latest
192
193
  VALKEY_IMAGE=valkey/valkey:latest
193
194
  APP_IMAGE=underpost/underpost-engine
194
- APP_TAG=v3.2.70
195
+ APP_TAG=v3.2.80
195
196
  PROXY_IMAGE=nginx:stable-alpine
196
197
  PROMETHEUS_IMAGE=prom/prometheus:latest
197
198
  GRAFANA_IMAGE=grafana/grafana:latest
@@ -361,6 +362,150 @@ datasources:
361
362
  `;
362
363
  }
363
364
 
365
+ /**
366
+ * Expands the deploy's `multiInstance` topology (from conf.instances.json, via
367
+ * {@link loadInstanceTopology}) into the per-variant descriptors the compose
368
+ * generators consume. Application-agnostic: it only reads variant code/slug/
369
+ * path and the default code. Returns null when the deploy is single-instance.
370
+ * @param {object} options - CLI options.
371
+ * @returns {?{defaultCode: string, variants: Array<object>}}
372
+ * @memberof UnderpostDockerCompose
373
+ */
374
+ static instanceTopology(options = {}) {
375
+ const deployId = options.deployId || DEFAULT_DEPLOY_ID;
376
+ let topology = null;
377
+ try {
378
+ topology = loadInstanceTopology(deployId);
379
+ } catch {
380
+ return null;
381
+ }
382
+ if (!topology || !Array.isArray(topology.variants) || topology.variants.length === 0) return null;
383
+ const defaultCode = topology.default || topology.variants[0].code;
384
+ const variants = topology.variants.map((v) => {
385
+ const path = v.path || '/';
386
+ const isDefault = !(v.slug || '');
387
+ return {
388
+ code: v.code,
389
+ slug: v.slug || '',
390
+ path,
391
+ isDefault,
392
+ // Service/container suffix: empty for the default variant so it keeps
393
+ // the historic `cyberia-server` / `cyberia-client` names (and their
394
+ // published host ports); `-forest`, `-test`, … for the rest.
395
+ suffix: v.slug ? `-${v.slug}` : '',
396
+ // Backend prefix strip, mirroring the K8s pathRewritePolicy: the Go
397
+ // server serves `/ws` at its root, so a `/FOREST` prefix is stripped.
398
+ strip: path !== '/',
399
+ };
400
+ });
401
+ return { defaultCode, variants };
402
+ }
403
+
404
+ /**
405
+ * Renders the nginx reverse-proxy config for the multi-instance Cyberia stack
406
+ * as a SINGLE localhost gateway (dev compose is single-host; production uses
407
+ * the K8s HTTPProxy, not this file). All three tiers share one origin, routed
408
+ * by URL sub-path so `http://localhost/` works with no /etc/hosts:
409
+ * /api/*, /assets/* -> engine-cyberia (shared content authority)
410
+ * /<slug>/ws -> cyberia-server-<slug> (prefix stripped; the Go
411
+ * runtime is instance-agnostic, selected by
412
+ * INSTANCE_CODE), and /ws -> the default server
413
+ * /<slug>, / -> cyberia-client-<slug> (prefix kept; the WASM
414
+ * reads the instance from the URL)
415
+ * Upstreams resolve lazily through Docker's embedded DNS so a variant that is
416
+ * briefly down never fails nginx startup. Returns null for single-instance.
417
+ * @param {object} options - CLI options.
418
+ * @returns {?string} nginx.conf content, or null.
419
+ * @memberof UnderpostDockerCompose
420
+ */
421
+ static instancesNginxContent(options = {}) {
422
+ const topology = UnderpostDockerCompose.instanceTopology(options);
423
+ if (!topology) return null;
424
+ const { variants } = topology;
425
+
426
+ const headers = ` proxy_set_header Host $host;
427
+ proxy_set_header X-Real-IP $remote_addr;
428
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
429
+ proxy_set_header X-Forwarded-Proto $scheme;
430
+ proxy_set_header Upgrade $http_upgrade;
431
+ proxy_set_header Connection $connection_upgrade;
432
+ proxy_read_timeout 3600s;`;
433
+
434
+ const proxyBlock = (variable, upstream, extra = '') =>
435
+ ` set ${variable} ${upstream};
436
+ ${extra} proxy_pass http://${variable};
437
+ ${headers}`;
438
+
439
+ // Websocket tier: /<path>/ws -> server (prefix stripped); default -> = /ws.
440
+ // Longest-prefix ensures /<path>/ws beats the /<path> client location.
441
+ const wsLocations = variants
442
+ .map((v) => {
443
+ const upstream = `cyberia-server${v.suffix}:8081`;
444
+ const varName = `$up_server_${v.slug || 'default'}`;
445
+ if (v.isDefault) return ` location = /ws {\n${proxyBlock(varName, upstream)}\n }`;
446
+ return ` location ${v.path}/ws {\n${proxyBlock(
447
+ varName,
448
+ upstream,
449
+ ` rewrite ^${v.path}/(.*)$ /$1 break;\n`,
450
+ )}\n }`;
451
+ })
452
+ .join('\n\n');
453
+
454
+ // Presentation tier: /<path> -> client (prefix kept); default -> catch-all /.
455
+ const clientLocations = variants
456
+ .map((v) => {
457
+ const upstream = `cyberia-client${v.suffix}:8081`;
458
+ const varName = `$up_client_${v.slug || 'default'}`;
459
+ if (v.isDefault) return ` location / {\n${proxyBlock(varName, upstream)}\n }`;
460
+ return ` location = ${v.path} { return 301 ${v.path}/; }
461
+ location ${v.path} {\n${proxyBlock(varName, upstream)}\n }`;
462
+ })
463
+ .join('\n\n');
464
+
465
+ const deployId = options.deployId || DEFAULT_DEPLOY_ID;
466
+ return `# Generated by 'underpost docker-compose --generate --deploy-id ${deployId} --docker-compose-id ${options.dockerComposeId || 'cyberia'}' — do not hand-edit.
467
+ # Source of truth: engine-private/conf/${deployId}/conf.instances.json (multiInstance).
468
+ # Single localhost gateway (dev compose). Access http://localhost/ (default),
469
+ # http://localhost/<PATH> per variant. Client origins point back here
470
+ # (compose.env CYBERIA_WS_ORIGIN=ws://localhost, CYBERIA_ENGINE_API_ORIGIN=http://localhost).
471
+
472
+ map $http_upgrade $connection_upgrade {
473
+ default upgrade;
474
+ '' close;
475
+ }
476
+
477
+ # Docker embedded DNS: resolve upstream names per request so a variant that is
478
+ # briefly down never fails nginx startup or config reload.
479
+ resolver 127.0.0.11 ipv6=off valid=10s;
480
+
481
+ proxy_http_version 1.1;
482
+
483
+ server {
484
+ listen 80 default_server;
485
+ server_name localhost 127.0.0.1 _;
486
+
487
+ location = /healthz {
488
+ access_log off;
489
+ return 200 "ok\\n";
490
+ add_header Content-Type text/plain;
491
+ }
492
+
493
+ # Shared content authority: REST API + engine-served assets (fonts, ui-icons).
494
+ location /api/ {
495
+ ${proxyBlock('$up_engine', 'engine-cyberia:4005')}
496
+ }
497
+
498
+ location /assets/ {
499
+ ${proxyBlock('$up_engine', 'engine-cyberia:4005')}
500
+ }
501
+
502
+ ${wsLocations}
503
+
504
+ ${clientLocations}
505
+ }
506
+ `;
507
+ }
508
+
364
509
  /**
365
510
  * Renders all dynamic supporting files: the nginx reverse-proxy config (from
366
511
  * PROXY_HOSTS), the monitoring configs (Prometheus + Grafana datasource), and
@@ -371,17 +516,25 @@ datasources:
371
516
  * @memberof UnderpostDockerCompose
372
517
  */
373
518
  static generate(options = {}) {
374
- // Custom workflow: docker-compose.yml, compose.env, and nginx.conf are
375
- // pre-authored in the canonical dir and used as-is — do NOT generate them.
376
- // Only (re)write the required MongoDB entrypoint (replica-set bootstrap),
377
- // the single generated infra artifact, into <canonical>/mongodb so the
378
- // stack stays self-contained under `--project-directory`.
519
+ // Custom workflow: docker-compose.yml and compose.env are hand-authored in
520
+ // the canonical dir and used as-is — do NOT generate them. Only (re)write
521
+ // the two generated artifacts: the MongoDB entrypoint (replica-set
522
+ // bootstrap) and, for a multi-instance deploy, nginx.conf (per-variant
523
+ // sub-path routing derived from the conf.instances.json multiInstance block,
524
+ // kept in sync with the service tiers hand-authored in docker-compose.yml).
379
525
  const composeIdBase = UnderpostDockerCompose.composeIdBase(options);
380
526
  if (composeIdBase) {
381
527
  const mongoEntrypointPath = UnderpostDockerCompose.resolve(`${composeIdBase}/mongodb/entrypoint.sh`);
382
528
  fs.mkdirpSync(nodePath.dirname(mongoEntrypointPath));
383
529
  fs.writeFileSync(mongoEntrypointPath, UnderpostDockerCompose.mongoEntrypointContent(), { mode: 0o755 });
384
530
  logger.info('mongodb entrypoint written (custom workflow)', { path: mongoEntrypointPath });
531
+
532
+ const nginx = UnderpostDockerCompose.instancesNginxContent(options);
533
+ if (nginx) {
534
+ const nginxPath = UnderpostDockerCompose.resolve(`${composeIdBase}/nginx.conf`);
535
+ fs.writeFileSync(nginxPath, nginx, 'utf8');
536
+ logger.info('multi-instance nginx.conf written', { path: nginxPath });
537
+ }
385
538
  return;
386
539
  }
387
540
 
@@ -491,9 +644,10 @@ datasources:
491
644
  silentOnError: true,
492
645
  });
493
646
 
494
- // Custom workflow: the compose file, compose.env, and nginx.conf are
495
- // hand-authored source — never prune them. Only drop the one generated
496
- // artifact (the mongo entrypoint), regenerated on the next --up/--generate.
647
+ // Custom workflow: docker-compose.yml and compose.env are hand-authored
648
+ // source — never prune them. Drop only the generated mongo entrypoint;
649
+ // nginx.conf is left in place so the mount stays valid and --generate/--up
650
+ // rewrites it.
497
651
  const composeIdBase = UnderpostDockerCompose.composeIdBase(options);
498
652
  if (composeIdBase) {
499
653
  const mongoEntrypointPath = UnderpostDockerCompose.resolve(`${composeIdBase}/mongodb/entrypoint.sh`);
package/src/cli/fs.js CHANGED
@@ -315,7 +315,6 @@ class UnderpostFileStorage {
315
315
  public_ids: [path],
316
316
  resource_type: 'raw',
317
317
  });
318
- logger.info('download result', downloadResult);
319
318
  await Downloader.downloadFile(downloadResult, zipPath);
320
319
 
321
320
  if (options.omitUnzip === true) {
package/src/cli/image.js CHANGED
@@ -117,20 +117,38 @@ class UnderpostImage {
117
117
  };
118
118
  // addBuildSecret('github_token', process.env.GITHUB_TOKEN);
119
119
  addBuildSecret('github_username', process.env.GITHUB_USERNAME);
120
- // Cloudinary creds power build-time asset pulls (`node bin fs --pull`).
121
- // addBuildSecret('cloudinary_cloud_name', process.env.CLOUDINARY_CLOUD_NAME);
122
- // addBuildSecret('cloudinary_api_key', process.env.CLOUDINARY_API_KEY);
123
- // addBuildSecret('cloudinary_api_secret', process.env.CLOUDINARY_API_SECRET);
120
+ // Build secrets are created dynamically: each `RUN --mount=type=secret,id=<x>`
121
+ // id maps to a host env var; only the ones actually set are passed, so a
122
+ // Dockerfile that omits (or comments out) a mount just never sees it. Extend
123
+ // this map — plus a merge of any caller-supplied `options.buildSecrets` — to
124
+ // add more without touching the call sites. Secrets never persist in image
125
+ // history (unlike build-args); they live in 0600 temp files removed post-build.
126
+ const BUILD_SECRET_ENV = {
127
+ // // Cloudinary creds power build-time asset pulls (`node bin fs --pull`).
128
+ // cloudinary_cloud_name: 'CLOUDINARY_CLOUD_NAME',
129
+ // cloudinary_api_key: 'CLOUDINARY_API_KEY',
130
+ // cloudinary_api_secret: 'CLOUDINARY_API_SECRET',
131
+ };
132
+ for (const [id, envVar] of Object.entries(BUILD_SECRET_ENV)) addBuildSecret(id, process.env[envVar]);
133
+ for (const [id, value] of Object.entries(options.buildSecrets || {})) addBuildSecret(id, value);
124
134
  const secretArgs = secretFlags.length ? ` ${secretFlags.join(' ')}` : '';
125
- if (secretFlags.length)
126
- logger.info('Passing host GitHub credentials as build secrets', { ids: secretFlags.length });
135
+ if (secretFlags.length) logger.info('Passing host credentials as build secrets', { ids: secretFlags.length });
136
+
137
+ // Non-secret build args (e.g. INSTANCE_CODES for engine-cyberia): injected
138
+ // dynamically from `options.buildArgs`, overriding the Dockerfile's ARG
139
+ // defaults. Unlike secrets these DO persist in image metadata, so only
140
+ // non-sensitive values belong here.
141
+ const buildArgFlags = Object.entries(options.buildArgs || {})
142
+ .filter(([, v]) => v !== undefined && v !== null && v !== '')
143
+ .map(([k, v]) => `--build-arg ${k}=${JSON.stringify(String(v))}`);
144
+ const buildArgStr = buildArgFlags.length ? ` ${buildArgFlags.join(' ')}` : '';
127
145
 
128
146
  if (path)
129
147
  try {
130
148
  shellExec(
131
149
  `cd ${path} && sudo podman build -f ./${
132
150
  dockerfileName && typeof dockerfileName === 'string' ? dockerfileName : 'Dockerfile'
133
- } -t ${imageName} --pull=never --cap-add=CAP_AUDIT_WRITE${cache}${secretArgs} --network host`,
151
+ } -t ${imageName} --pull=never --cap-add=CAP_AUDIT_WRITE${cache}${secretArgs}${buildArgStr} --network host`,
134
152
  );
135
153
  } finally {
136
154
  for (const file of secretTmpFiles) {
package/src/cli/index.js CHANGED
@@ -108,6 +108,10 @@ program
108
108
  .option('--edit', 'Edit last commit.')
109
109
  .option('--deploy-id <deploy-id>', 'Sets the deployment configuration ID for the commit context.')
110
110
  .option('--cached', 'Commit staged changes only or context.')
111
+ .option(
112
+ '--init-repo [origin]',
113
+ 'Initialize a git repository at the specified path. Optionally set the git remote origin URL.',
114
+ )
111
115
  .option('--hashes <hashes>', 'Comma-separated list of specific file hashes of commits.')
112
116
  .option('--extension <extension>', 'specific file extensions of commits.')
113
117
  .option('--changelog', 'Print the plain changelog of the last N commits (see --from-n-commit, default 1).')
@@ -758,6 +762,7 @@ program
758
762
  '--test',
759
763
  'Enables test/generic-purpose mode for the runner (e.g. use self-signed TLS instead of cert-manager).',
760
764
  )
765
+ .option('--branch <branch>', 'Sets the branch for git operations (default: current branch).')
761
766
  .description('Runs specified scripts using various runners.')
762
767
  .action(Underpost.run.callback);
763
768
 
@@ -135,6 +135,10 @@ const buildVersionBumpTargets = () => [
135
135
  file: 'src/runtime/engine-cyberia/docker-compose.yml',
136
136
  patterns: /(_TAG:-v)\d+\.\d+\.\d+/g,
137
137
  },
138
+ {
139
+ file: 'engine-private/conf/dd-cyberia/docker-compose/cyberia/docker-compose.yml',
140
+ patterns: /(_TAG:-v)\d+\.\d+\.\d+/g,
141
+ },
138
142
 
139
143
  // ── Cyberia CLI dev image tar/name defaults (bin/cyberia.js). ──
140
144
  {
@@ -21,6 +21,7 @@ import {
21
21
  getDataDeploy,
22
22
  buildReplicaId,
23
23
  writeEnv,
24
+ readConfInstances,
24
25
  } from '../server/conf.js';
25
26
  import { buildClient, unzipClientBuild, mergeClientBuildZip } from '../client-builder/client-build.js';
26
27
  import { DefaultConf } from '../../conf.js';
@@ -145,6 +146,14 @@ class UnderpostRepository {
145
146
  ) {
146
147
  if (!repoPath) repoPath = '.';
147
148
 
149
+ if (options.initRepo) {
150
+ Underpost.repo.initLocalRepo({
151
+ path: repoPath,
152
+ origin: typeof options.initRepo === 'string' ? options.initRepo : undefined,
153
+ });
154
+ return;
155
+ }
156
+
148
157
  if (options.hasChanges) {
149
158
  const status = shellExec(`cd ${repoPath} && git status --porcelain`, {
150
159
  stdout: true,
@@ -1854,8 +1863,10 @@ Prevent build private config repo.`,
1854
1863
  const confPath = `./engine-private/conf/${deployId}/conf.instances.json`;
1855
1864
  if (!fs.existsSync(confPath)) continue;
1856
1865
  try {
1857
- const instances = JSON.parse(fs.readFileSync(confPath, 'utf8'));
1858
- const match = instances.find((i) => i && i.runtime === runtime);
1866
+ // readConfInstances returns the bare array of entries. The template
1867
+ // entries carry runtime + metadata.repository, so the un-expanded list
1868
+ // is enough here (no need to expand variants).
1869
+ const match = readConfInstances(deployId).find((i) => i && i.runtime === runtime);
1859
1870
  if (match && match.metadata && match.metadata.repository) {
1860
1871
  logger.info(`[resolveInstanceRepo] resolved from ${confPath}`, {
1861
1872
  runtime,