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.
- package/.github/workflows/publish.ci.yml +3 -3
- package/.github/workflows/release.cd.yml +1 -1
- package/CHANGELOG.md +1177 -1038
- package/CLI-HELP.md +3 -1
- package/README.md +3 -3
- package/bin/build.js +10 -4
- package/docker-compose.yml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/package.json +10 -10
- package/scripts/test-monitor.sh +1 -1
- package/src/api/core/core.controller.js +4 -65
- package/src/api/core/core.router.js +8 -14
- package/src/api/default/default.controller.js +2 -70
- package/src/api/default/default.router.js +7 -17
- package/src/api/document/document.controller.js +5 -77
- package/src/api/document/document.router.js +9 -13
- package/src/api/file/file.controller.js +9 -53
- package/src/api/file/file.router.js +14 -6
- package/src/api/test/test.controller.js +8 -53
- package/src/api/test/test.router.js +1 -4
- package/src/cli/cluster.js +31 -11
- package/src/cli/db.js +4 -2
- package/src/cli/deploy.js +51 -9
- package/src/cli/docker-compose.js +163 -9
- package/src/cli/fs.js +0 -1
- package/src/cli/image.js +25 -7
- package/src/cli/index.js +5 -0
- package/src/cli/release.js +4 -0
- package/src/cli/repository.js +13 -2
- package/src/cli/run.js +151 -78
- package/src/cli/ssh.js +30 -11
- package/src/client/components/core/Modal.js +38 -4
- package/src/index.js +1 -1
- package/src/server/conf.js +163 -0
- package/src/server/downloader.js +3 -3
- 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(
|
|
21
|
-
|
|
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 {
|
|
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 =
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
/**
|
package/src/cli/cluster.js
CHANGED
|
@@ -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
|
-
|
|
907
|
-
|
|
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(`
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
shellExec(`
|
|
1207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
829
|
-
|
|
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.
|
|
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
|
-
|
|
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: ${
|
|
1287
|
+
- name: ${podVolumeName}
|
|
1246
1288
|
mountPath: ${volumeMountPath}
|
|
1247
1289
|
${secret ? ` readOnly: true\n` : ''}`;
|
|
1248
1290
|
|
|
1249
1291
|
_volumes += `
|
|
1250
|
-
- name: ${
|
|
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.
|
|
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
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
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:
|
|
495
|
-
//
|
|
496
|
-
//
|
|
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
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
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
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
|
-
|
|
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
|
|
package/src/cli/release.js
CHANGED
|
@@ -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
|
{
|
package/src/cli/repository.js
CHANGED
|
@@ -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
|
-
|
|
1858
|
-
|
|
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,
|