@kne/fastify-app-manager 0.1.8 → 0.1.10
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/README.md +198 -21
- package/index.js +17 -0
- package/libs/controllers/app.js +167 -0
- package/libs/controllers/gateway.js +16 -2
- package/libs/services/app.js +175 -28
- package/libs/services/bootstrap.js +160 -3
- package/libs/services/dbops.js +2 -10
- package/libs/utils/dbIdentity.js +16 -7
- package/libs/utils/env.js +39 -7
- package/libs/utils/loadMonitor.js +73 -0
- package/libs/utils/logFiles.js +168 -20
- package/libs/utils/logRotate.js +162 -0
- package/libs/utils/logTime.js +132 -0
- package/libs/utils/pathRewrite.js +28 -3
- package/libs/utils/pm2.js +7 -0
- package/libs/utils/requestMetrics.js +132 -0
- package/package.json +3 -2
package/libs/services/app.js
CHANGED
|
@@ -3,10 +3,12 @@ const path = require('node:path');
|
|
|
3
3
|
const crypto = require('node:crypto');
|
|
4
4
|
const fs = require('fs-extra');
|
|
5
5
|
const portPool = require('../utils/portPool');
|
|
6
|
-
const { mergeEnv, maskEnvForResponse, applyEnvPatch, mergePm2Config, normalizeSecretKeys, collectSecretKeys } = require('../utils/env');
|
|
6
|
+
const { mergeEnv, maskEnvForResponse, applyEnvPatch, mergePm2Config, normalizeSecretKeys, collectSecretKeys, isSystemEnvKey, omitSystemEnv } = require('../utils/env');
|
|
7
7
|
const { prepareVersionArtifact } = require('../utils/version');
|
|
8
8
|
const { injectEntryHtml } = require('../utils/entryInject');
|
|
9
|
-
const { ensureLogFiles, readLogTail, readLastLines, appendLog } = require('../utils/logFiles');
|
|
9
|
+
const { ensureLogFiles, readLogTail, readLastLines, appendLog, countLines } = require('../utils/logFiles');
|
|
10
|
+
const { currentFileName, isCurrentFileName, resolveLogFile, listLogFiles } = require('../utils/logRotate');
|
|
11
|
+
const { toIsoInTz, convertLogText } = require('../utils/logTime');
|
|
10
12
|
const { runSqlMigrations, MIGRATION_ACTIONS } = require('../utils/migrate');
|
|
11
13
|
const { listSqlFiles } = require('../utils/validatePackage');
|
|
12
14
|
const pm2Util = require('../utils/pm2');
|
|
@@ -43,6 +45,27 @@ module.exports = fp(async (fastify, options) => {
|
|
|
43
45
|
}
|
|
44
46
|
return category.code ? { code: category.code, name: category.name || category.code } : null;
|
|
45
47
|
};
|
|
48
|
+
// 应用入口:label + 相对应用根的 path;去空、补前导 /、按 path 去重
|
|
49
|
+
const normalizeEntries = entries => {
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
return (Array.isArray(entries) ? entries : []).reduce((result, item) => {
|
|
52
|
+
const label = String(item?.label || '').trim();
|
|
53
|
+
const rawPath = String(item?.path || '').trim();
|
|
54
|
+
if (!label || !rawPath) {
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
const entryPath = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
|
|
58
|
+
if (!seen.has(entryPath)) {
|
|
59
|
+
seen.add(entryPath);
|
|
60
|
+
result.push({ label, path: entryPath });
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}, []);
|
|
64
|
+
};
|
|
65
|
+
const resolveEntries = appLike => {
|
|
66
|
+
const base = mountPrefix(appLike.name);
|
|
67
|
+
return normalizeEntries(appLike.options?.entries).map(entry => Object.assign({}, entry, { url: `${base}${entry.path}` }));
|
|
68
|
+
};
|
|
46
69
|
|
|
47
70
|
const toPublicApp = app => {
|
|
48
71
|
if (!app) {
|
|
@@ -57,6 +80,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
57
80
|
json.pathUrl = `${mountPrefix(json.name)}/`;
|
|
58
81
|
json.category = json.options?.category || null;
|
|
59
82
|
json.isPublic = isAppPublic(json);
|
|
83
|
+
json.entries = resolveEntries(json);
|
|
60
84
|
return json;
|
|
61
85
|
};
|
|
62
86
|
|
|
@@ -69,7 +93,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
69
93
|
};
|
|
70
94
|
|
|
71
95
|
const create = async data => {
|
|
72
|
-
const { name, label, domain, icon, description, category, isPublic, env, pm2Config, options: appOptions } = data;
|
|
96
|
+
const { name, label, domain, icon, description, category, isPublic, entries, env, pm2Config, options: appOptions } = data;
|
|
73
97
|
if (!name || !NAME_RE.test(name)) {
|
|
74
98
|
httpError(400, 'invalid name: use lowercase slug [a-z0-9-]');
|
|
75
99
|
}
|
|
@@ -109,6 +133,9 @@ module.exports = fp(async (fastify, options) => {
|
|
|
109
133
|
if (isPublic !== undefined) {
|
|
110
134
|
normalizedOptions.isPublic = !!isPublic;
|
|
111
135
|
}
|
|
136
|
+
if (entries !== undefined) {
|
|
137
|
+
normalizedOptions.entries = normalizeEntries(entries);
|
|
138
|
+
}
|
|
112
139
|
|
|
113
140
|
const app = await models.app.create({
|
|
114
141
|
name,
|
|
@@ -116,7 +143,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
116
143
|
domain: domain || null,
|
|
117
144
|
icon: icon || null,
|
|
118
145
|
description: description || null,
|
|
119
|
-
env: env
|
|
146
|
+
env: omitSystemEnv(env, options.systemEnvKeys),
|
|
120
147
|
pm2Config: pm2Config || {},
|
|
121
148
|
options: normalizedOptions,
|
|
122
149
|
port,
|
|
@@ -128,7 +155,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
128
155
|
return toPublicApp(app);
|
|
129
156
|
};
|
|
130
157
|
|
|
131
|
-
const save = async ({ name, category, isPublic, ...data }) => {
|
|
158
|
+
const save = async ({ name, category, isPublic, entries, ...data }) => {
|
|
132
159
|
const app = await getByName(name);
|
|
133
160
|
const omit = ['name', 'port', 'rootPath', 'pm2Name', 'status', 'currentVersionId'];
|
|
134
161
|
const patch = {};
|
|
@@ -150,7 +177,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
150
177
|
}
|
|
151
178
|
if (patch.options && patch.options.secretEnvKeys !== undefined) {
|
|
152
179
|
patch.options = Object.assign({}, patch.options, {
|
|
153
|
-
secretEnvKeys: normalizeSecretKeys(patch.options.secretEnvKeys)
|
|
180
|
+
secretEnvKeys: normalizeSecretKeys([...resolveSecretEnvKeys(app), ...patch.options.secretEnvKeys])
|
|
154
181
|
});
|
|
155
182
|
}
|
|
156
183
|
if (category !== undefined) {
|
|
@@ -159,16 +186,19 @@ module.exports = fp(async (fastify, options) => {
|
|
|
159
186
|
if (isPublic !== undefined) {
|
|
160
187
|
patch.options = Object.assign({}, patch.options || app.options || {}, { isPublic: !!isPublic });
|
|
161
188
|
}
|
|
189
|
+
if (entries !== undefined) {
|
|
190
|
+
patch.options = Object.assign({}, patch.options || app.options || {}, { entries: normalizeEntries(entries) });
|
|
191
|
+
}
|
|
162
192
|
await app.update(patch);
|
|
163
193
|
return toPublicApp(app);
|
|
164
194
|
};
|
|
165
195
|
|
|
166
196
|
const saveEnv = async ({ name, env, secretEnvKeys: nextSecretKeys } = {}) => {
|
|
167
197
|
const app = await getByName(name);
|
|
168
|
-
|
|
169
|
-
const secretEnvKeys =
|
|
198
|
+
// 密钥类型不可撤销,只随变量删除而移除
|
|
199
|
+
const secretEnvKeys = normalizeSecretKeys([...resolveSecretEnvKeys(app), ...(nextSecretKeys || [])]);
|
|
170
200
|
const nextEnv = applyEnvPatch(app.env || {}, env || {}, Object.assign({}, options, { secretEnvKeys }));
|
|
171
|
-
const cleanedSecrets = secretEnvKeys.filter(key => Object.prototype.hasOwnProperty.call(nextEnv, key));
|
|
201
|
+
const cleanedSecrets = secretEnvKeys.filter(key => !isSystemEnvKey(key, options.systemEnvKeys) && Object.prototype.hasOwnProperty.call(nextEnv, key));
|
|
172
202
|
await app.update({
|
|
173
203
|
env: nextEnv,
|
|
174
204
|
options: Object.assign({}, app.options || {}, { secretEnvKeys: cleanedSecrets })
|
|
@@ -209,7 +239,8 @@ module.exports = fp(async (fastify, options) => {
|
|
|
209
239
|
description: app.description,
|
|
210
240
|
category: app.options?.category || null,
|
|
211
241
|
isPublic: isAppPublic(app),
|
|
212
|
-
pathUrl: `${mountPrefix(app.name)}
|
|
242
|
+
pathUrl: `${mountPrefix(app.name)}/`,
|
|
243
|
+
entries: resolveEntries(app)
|
|
213
244
|
}));
|
|
214
245
|
return { pageData, totalCount: pageData.length };
|
|
215
246
|
};
|
|
@@ -478,6 +509,26 @@ module.exports = fp(async (fastify, options) => {
|
|
|
478
509
|
await app.reload();
|
|
479
510
|
}
|
|
480
511
|
|
|
512
|
+
if (typeof options.resolveSystemEnv === 'function') {
|
|
513
|
+
const systemEnv = await options.resolveSystemEnv({ app, version, serverDir });
|
|
514
|
+
if (systemEnv && typeof systemEnv === 'object') {
|
|
515
|
+
const persisted = {};
|
|
516
|
+
for (const [key, value] of Object.entries(systemEnv)) {
|
|
517
|
+
if (value == null) {
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
env[key] = String(value);
|
|
521
|
+
if ((options.systemEnvKeys || []).includes(key) && app.env?.[key] !== env[key]) {
|
|
522
|
+
persisted[key] = env[key];
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (Object.keys(persisted).length) {
|
|
526
|
+
await app.update({ env: Object.assign({}, app.env || {}, persisted) });
|
|
527
|
+
await app.reload();
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
481
532
|
const pathBase = mountPrefix(app.name);
|
|
482
533
|
// Domain-first: inject `/` so custom Host SPA works; path mode then relies on gateway rewrite fallback.
|
|
483
534
|
// Path-only: inject `/app/{name}` so runtime* matches strip-prefix gateway.
|
|
@@ -661,9 +712,17 @@ module.exports = fp(async (fastify, options) => {
|
|
|
661
712
|
}
|
|
662
713
|
}
|
|
663
714
|
|
|
715
|
+
const snapshot = app.toJSON();
|
|
664
716
|
await models.appVersion.destroy({ where: { appName: name } });
|
|
665
717
|
await fs.remove(app.rootPath).catch(() => {});
|
|
666
718
|
await app.destroy();
|
|
719
|
+
if (typeof options.onAppRemoved === 'function') {
|
|
720
|
+
try {
|
|
721
|
+
await options.onAppRemoved({ app: snapshot });
|
|
722
|
+
} catch (e) {
|
|
723
|
+
fastify.log.warn({ err: e, appName: name }, 'onAppRemoved hook failed');
|
|
724
|
+
}
|
|
725
|
+
}
|
|
667
726
|
return {
|
|
668
727
|
removed: true,
|
|
669
728
|
export: exportResult,
|
|
@@ -671,10 +730,103 @@ module.exports = fp(async (fastify, options) => {
|
|
|
671
730
|
};
|
|
672
731
|
};
|
|
673
732
|
|
|
674
|
-
const
|
|
733
|
+
const logsDirOf = app => path.join(app.rootPath, 'logs');
|
|
734
|
+
const currentLogFile = (app, stream) => path.join(logsDirOf(app), currentFileName(stream));
|
|
735
|
+
|
|
736
|
+
const resolveExistingLogFile = async (app, fileName) => {
|
|
737
|
+
let filePath;
|
|
738
|
+
try {
|
|
739
|
+
filePath = resolveLogFile(logsDirOf(app), fileName);
|
|
740
|
+
} catch (e) {
|
|
741
|
+
httpError(400, e.message);
|
|
742
|
+
}
|
|
743
|
+
if (!(await fs.pathExists(filePath))) {
|
|
744
|
+
httpError(404, `log file ${fileName} not found`);
|
|
745
|
+
}
|
|
746
|
+
return filePath;
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
const logs = async ({ name, stream = 'out', file, perPage = 100, currentPage = 1, beforeLine } = {}) => {
|
|
750
|
+
const app = await getByName(name);
|
|
751
|
+
const filePath = file ? await resolveExistingLogFile(app, file) : currentLogFile(app, stream);
|
|
752
|
+
return readLogTail(filePath, { perPage, currentPage, beforeLine, timezone: options.logTimezone });
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
const logFiles = async ({ name }) => {
|
|
675
756
|
const app = await getByName(name);
|
|
676
|
-
const
|
|
677
|
-
return
|
|
757
|
+
const pageData = (await listLogFiles(logsDirOf(app))).map(item => Object.assign({}, item, { mtime: toIsoInTz(item.mtime, options.logTimezone) }));
|
|
758
|
+
return { pageData, totalCount: pageData.length };
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
const resolveAppLogFile = async ({ name, file }) => {
|
|
762
|
+
const app = await getByName(name);
|
|
763
|
+
const filePath = await resolveExistingLogFile(app, file);
|
|
764
|
+
const { size } = await fs.stat(filePath);
|
|
765
|
+
return { path: filePath, fileName: file, size, compressed: file.endsWith('.gz') };
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
const resolveAppLogFiles = async ({ name, files = [] }) => {
|
|
769
|
+
const app = await getByName(name);
|
|
770
|
+
const items = [];
|
|
771
|
+
for (const fileName of Array.from(new Set(files))) {
|
|
772
|
+
const filePath = await resolveExistingLogFile(app, fileName);
|
|
773
|
+
const { size, mtime } = await fs.stat(filePath);
|
|
774
|
+
items.push({ path: filePath, fileName, size, mtime, compressed: fileName.endsWith('.gz') });
|
|
775
|
+
}
|
|
776
|
+
return { name: app.name, files: items };
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
const removeLogFiles = async ({ name, files = [] }) => {
|
|
780
|
+
const app = await getByName(name);
|
|
781
|
+
const targets = [];
|
|
782
|
+
for (const fileName of files) {
|
|
783
|
+
if (isCurrentFileName(fileName)) {
|
|
784
|
+
httpError(400, `cannot remove current log file ${fileName}`);
|
|
785
|
+
}
|
|
786
|
+
try {
|
|
787
|
+
targets.push({ fileName, filePath: resolveLogFile(logsDirOf(app), fileName) });
|
|
788
|
+
} catch (e) {
|
|
789
|
+
httpError(400, e.message);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
const removed = [];
|
|
793
|
+
for (const { fileName, filePath } of targets) {
|
|
794
|
+
if (await fs.pathExists(filePath)) {
|
|
795
|
+
await fs.remove(filePath);
|
|
796
|
+
removed.push(fileName);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return { removed };
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
const getLoadStore = () => fastify[options.name].loadStore;
|
|
803
|
+
|
|
804
|
+
const load = async ({ name }) => {
|
|
805
|
+
const app = await getByName(name);
|
|
806
|
+
const store = getLoadStore();
|
|
807
|
+
return {
|
|
808
|
+
intervalMs: options.loadSampleIntervalMs > 0 ? options.loadSampleIntervalMs : 0,
|
|
809
|
+
current: store ? store.latest(app.name) : null,
|
|
810
|
+
pageData: store ? store.history(app.name) : []
|
|
811
|
+
};
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
const emitAppLog = async (appName, stream, content) => {
|
|
815
|
+
const hub = getLogHub();
|
|
816
|
+
if (!hub) {
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
const app = await models.app.findOne({ where: { name: appName } });
|
|
820
|
+
if (!app) {
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
hub.emit(`log:${appName}`, {
|
|
824
|
+
appName,
|
|
825
|
+
stream,
|
|
826
|
+
content: convertLogText(content, options.logTimezone),
|
|
827
|
+
line: await countLines(currentLogFile(app, stream)),
|
|
828
|
+
loggedAt: toIsoInTz(new Date(), options.logTimezone)
|
|
829
|
+
});
|
|
678
830
|
};
|
|
679
831
|
|
|
680
832
|
const findByHost = async (host, { running = true } = {}) => {
|
|
@@ -707,34 +859,29 @@ module.exports = fp(async (fastify, options) => {
|
|
|
707
859
|
syncAllStatuses,
|
|
708
860
|
remove,
|
|
709
861
|
logs,
|
|
862
|
+
logFiles,
|
|
863
|
+
resolveAppLogFile,
|
|
864
|
+
resolveAppLogFiles,
|
|
865
|
+
removeLogFiles,
|
|
866
|
+
load,
|
|
867
|
+
getLoadStore,
|
|
710
868
|
toPublicApp,
|
|
711
869
|
mountPrefix,
|
|
712
870
|
findByHost,
|
|
713
871
|
findByPathName,
|
|
714
872
|
readLastLines: async (name, stream, n) => {
|
|
715
873
|
const app = await getByName(name);
|
|
716
|
-
|
|
717
|
-
return readLastLines(file, n);
|
|
874
|
+
return readLastLines(currentLogFile(app, stream), n, { timezone: options.logTimezone });
|
|
718
875
|
},
|
|
719
876
|
getLogHub,
|
|
877
|
+
emitAppLog,
|
|
720
878
|
appendAppLog: async (appName, stream, content) => {
|
|
721
879
|
const app = await models.app.findOne({ where: { name: appName } });
|
|
722
880
|
if (!app) {
|
|
723
881
|
return;
|
|
724
882
|
}
|
|
725
|
-
|
|
726
|
-
await
|
|
727
|
-
const hub = getLogHub();
|
|
728
|
-
if (hub) {
|
|
729
|
-
const { totalCount } = await readLogTail(file, { perPage: 1, currentPage: 1 });
|
|
730
|
-
hub.emit(`log:${appName}`, {
|
|
731
|
-
appName,
|
|
732
|
-
stream,
|
|
733
|
-
content,
|
|
734
|
-
line: totalCount,
|
|
735
|
-
loggedAt: new Date().toISOString()
|
|
736
|
-
});
|
|
737
|
-
}
|
|
883
|
+
await appendLog(currentLogFile(app, stream), content);
|
|
884
|
+
await emitAppLog(appName, stream, content);
|
|
738
885
|
}
|
|
739
886
|
}
|
|
740
887
|
});
|
|
@@ -1,14 +1,34 @@
|
|
|
1
1
|
const fp = require('fastify-plugin');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const fs = require('fs-extra');
|
|
2
4
|
const { EventEmitter } = require('node:events');
|
|
3
5
|
const pm2Util = require('../utils/pm2');
|
|
6
|
+
const { LOG_STREAMS, rotateIfNeeded, compressArchive, applyRetention } = require('../utils/logRotate');
|
|
7
|
+
const { summarizeProcesses, createLoadStore } = require('../utils/loadMonitor');
|
|
8
|
+
const { createRequestMetrics } = require('../utils/requestMetrics');
|
|
9
|
+
const { toIsoInTz } = require('../utils/logTime');
|
|
10
|
+
|
|
11
|
+
const OFFLINE_LOAD = { status: 'offline', cpu: 0, memory: 0, instances: 0, pids: [], uptime: 0, restarts: 0 };
|
|
4
12
|
|
|
5
13
|
module.exports = fp(async (fastify, options) => {
|
|
6
14
|
const logHub = new EventEmitter();
|
|
7
15
|
logHub.setMaxListeners(0);
|
|
8
16
|
fastify[options.name].logHub = logHub;
|
|
9
17
|
|
|
18
|
+
const loadStore = createLoadStore({
|
|
19
|
+
size: options.loadSampleIntervalMs > 0 ? Math.ceil((options.loadHistoryMinutes * 60 * 1000) / options.loadSampleIntervalMs) : 1
|
|
20
|
+
});
|
|
21
|
+
fastify[options.name].loadStore = loadStore;
|
|
22
|
+
|
|
23
|
+
const requestMetrics = createRequestMetrics({ intervalMs: options.loadSampleIntervalMs });
|
|
24
|
+
fastify[options.name].requestMetrics = requestMetrics;
|
|
25
|
+
|
|
10
26
|
let bus = null;
|
|
11
27
|
let connected = false;
|
|
28
|
+
let rotateTimer = null;
|
|
29
|
+
let rotating = null;
|
|
30
|
+
let loadTimer = null;
|
|
31
|
+
let sampling = null;
|
|
12
32
|
|
|
13
33
|
const mapPm2NameToApp = async pm2Name => {
|
|
14
34
|
const { models } = fastify[options.name];
|
|
@@ -41,7 +61,8 @@ module.exports = fp(async (fastify, options) => {
|
|
|
41
61
|
if (!content) {
|
|
42
62
|
return;
|
|
43
63
|
}
|
|
44
|
-
|
|
64
|
+
// PM2 already writes out_file/error_file; the bus only feeds SSE subscribers.
|
|
65
|
+
await fastify[options.name].services.app.emitAppLog(app.name, stream, content);
|
|
45
66
|
} catch (e) {
|
|
46
67
|
fastify.log.debug({ err: e }, 'log bus handler error');
|
|
47
68
|
}
|
|
@@ -52,16 +73,33 @@ module.exports = fp(async (fastify, options) => {
|
|
|
52
73
|
fastify.log.warn({ err: e }, 'pm2 launchBus failed');
|
|
53
74
|
}
|
|
54
75
|
|
|
76
|
+
if (options.logRotateIntervalMs > 0) {
|
|
77
|
+
rotateTimer = setInterval(() => {
|
|
78
|
+
rotateLogs().catch(err => fastify.log.warn({ err }, 'log rotation failed'));
|
|
79
|
+
}, options.logRotateIntervalMs);
|
|
80
|
+
rotateTimer.unref?.();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (options.loadSampleIntervalMs > 0) {
|
|
84
|
+
loadTimer = setInterval(() => {
|
|
85
|
+
sampleLoad().catch(err => fastify.log.debug({ err }, 'load sampling failed'));
|
|
86
|
+
}, options.loadSampleIntervalMs);
|
|
87
|
+
loadTimer.unref?.();
|
|
88
|
+
}
|
|
89
|
+
|
|
55
90
|
// 父应用重启后不阻塞 ready:后台按 PM2 真实状态异步对齐 DB
|
|
56
91
|
setImmediate(() => {
|
|
57
92
|
reconcile().catch(err => {
|
|
58
93
|
fastify.log.warn({ err }, 'async reconcile/sync statuses failed');
|
|
59
94
|
});
|
|
95
|
+
rotateLogs().catch(err => fastify.log.warn({ err }, 'log rotation failed'));
|
|
96
|
+
if (options.loadSampleIntervalMs > 0) {
|
|
97
|
+
sampleLoad().catch(err => fastify.log.debug({ err }, 'load sampling failed'));
|
|
98
|
+
}
|
|
60
99
|
});
|
|
61
100
|
};
|
|
62
101
|
|
|
63
102
|
const fsEnsureAppsRoot = async () => {
|
|
64
|
-
const fs = require('fs-extra');
|
|
65
103
|
await fs.ensureDir(options.appsRoot);
|
|
66
104
|
};
|
|
67
105
|
|
|
@@ -73,7 +111,126 @@ module.exports = fp(async (fastify, options) => {
|
|
|
73
111
|
return [];
|
|
74
112
|
};
|
|
75
113
|
|
|
114
|
+
const runRotation = async ({ now = new Date() } = {}) => {
|
|
115
|
+
const { models } = fastify[options.name];
|
|
116
|
+
const apps = await models.app.findAll({ attributes: ['name', 'rootPath'] });
|
|
117
|
+
const targets = [];
|
|
118
|
+
for (const app of apps) {
|
|
119
|
+
const logsDir = path.join(app.rootPath, 'logs');
|
|
120
|
+
if (await fs.pathExists(logsDir)) {
|
|
121
|
+
targets.push({ name: app.name, logsDir });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const rotated = [];
|
|
126
|
+
for (const { name, logsDir } of targets) {
|
|
127
|
+
for (const stream of LOG_STREAMS) {
|
|
128
|
+
try {
|
|
129
|
+
const result = await rotateIfNeeded({
|
|
130
|
+
logsDir,
|
|
131
|
+
stream,
|
|
132
|
+
maxSize: options.logMaxSize,
|
|
133
|
+
daily: options.logRotateDaily,
|
|
134
|
+
timezone: options.logTimezone,
|
|
135
|
+
now
|
|
136
|
+
});
|
|
137
|
+
if (result) {
|
|
138
|
+
rotated.push(Object.assign({ name, stream }, result));
|
|
139
|
+
}
|
|
140
|
+
} catch (e) {
|
|
141
|
+
fastify.log.warn({ err: e, app: name, stream }, 'rotate log file failed');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Until PM2 reopens its handles it keeps writing into the renamed file, so compress only afterwards.
|
|
147
|
+
if (rotated.length && connected) {
|
|
148
|
+
try {
|
|
149
|
+
await pm2Util.reloadLogs();
|
|
150
|
+
} catch (e) {
|
|
151
|
+
fastify.log.warn({ err: e }, 'pm2 reloadLogs failed');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (options.logCompress) {
|
|
156
|
+
for (const item of rotated) {
|
|
157
|
+
try {
|
|
158
|
+
item.archive = await compressArchive(item.archive);
|
|
159
|
+
} catch (e) {
|
|
160
|
+
fastify.log.warn({ err: e, app: item.name, archive: item.archive }, 'compress log archive failed');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const { name, logsDir } of targets) {
|
|
166
|
+
for (const stream of LOG_STREAMS) {
|
|
167
|
+
try {
|
|
168
|
+
await applyRetention({ logsDir, stream, maxFiles: options.logRetentionMaxFiles, maxDays: options.logRetentionDays, now });
|
|
169
|
+
} catch (e) {
|
|
170
|
+
fastify.log.warn({ err: e, app: name, stream }, 'log retention failed');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return rotated.map(({ name, stream, archive, reason }) => ({ name, stream, fileName: path.basename(archive), reason }));
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const rotateLogs = async opts => {
|
|
179
|
+
if (rotating) {
|
|
180
|
+
return rotating;
|
|
181
|
+
}
|
|
182
|
+
rotating = runRotation(opts).finally(() => {
|
|
183
|
+
rotating = null;
|
|
184
|
+
});
|
|
185
|
+
return rotating;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const runSampling = async ({ now = new Date() } = {}) => {
|
|
189
|
+
if (!connected) {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
const { models } = fastify[options.name];
|
|
193
|
+
const summary = summarizeProcesses(await pm2Util.list(), { now: now.getTime() });
|
|
194
|
+
const apps = await models.app.findAll({ attributes: ['name', 'pm2Name'] });
|
|
195
|
+
const base = { ts: now.getTime(), sampledAt: toIsoInTz(now, options.logTimezone) };
|
|
196
|
+
const samples = apps.map(app => {
|
|
197
|
+
const sample = Object.assign({ name: app.name }, base, summary.get(app.pm2Name) || OFFLINE_LOAD, {
|
|
198
|
+
requests: requestMetrics.snapshot(app.name, { now: base.ts })
|
|
199
|
+
});
|
|
200
|
+
loadStore.push(app.name, sample);
|
|
201
|
+
return sample;
|
|
202
|
+
});
|
|
203
|
+
const names = apps.map(app => app.name);
|
|
204
|
+
loadStore.retain(names);
|
|
205
|
+
requestMetrics.retain(names);
|
|
206
|
+
return samples;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const sampleLoad = async opts => {
|
|
210
|
+
if (sampling) {
|
|
211
|
+
return sampling;
|
|
212
|
+
}
|
|
213
|
+
sampling = runSampling(opts).finally(() => {
|
|
214
|
+
sampling = null;
|
|
215
|
+
});
|
|
216
|
+
return sampling;
|
|
217
|
+
};
|
|
218
|
+
|
|
76
219
|
const onClose = async () => {
|
|
220
|
+
if (rotateTimer) {
|
|
221
|
+
clearInterval(rotateTimer);
|
|
222
|
+
rotateTimer = null;
|
|
223
|
+
}
|
|
224
|
+
if (loadTimer) {
|
|
225
|
+
clearInterval(loadTimer);
|
|
226
|
+
loadTimer = null;
|
|
227
|
+
}
|
|
228
|
+
if (rotating) {
|
|
229
|
+
await rotating.catch(() => {});
|
|
230
|
+
}
|
|
231
|
+
if (sampling) {
|
|
232
|
+
await sampling.catch(() => {});
|
|
233
|
+
}
|
|
77
234
|
if (bus) {
|
|
78
235
|
try {
|
|
79
236
|
bus.close?.();
|
|
@@ -87,6 +244,6 @@ module.exports = fp(async (fastify, options) => {
|
|
|
87
244
|
};
|
|
88
245
|
|
|
89
246
|
Object.assign(fastify[options.name].services, {
|
|
90
|
-
bootstrap: { onReady, onClose, reconcile }
|
|
247
|
+
bootstrap: { onReady, onClose, reconcile, rotateLogs, sampleLoad }
|
|
91
248
|
});
|
|
92
249
|
});
|
package/libs/services/dbops.js
CHANGED
|
@@ -4,7 +4,7 @@ const fs = require('fs-extra');
|
|
|
4
4
|
const AdmZip = require('adm-zip');
|
|
5
5
|
const { createSequelizeFromEnv, SQL_MIGRATIONS_TABLE } = require('../utils/migrate');
|
|
6
6
|
const { resolveAppDbEnv, hasAppDbConfig } = require('../utils/env');
|
|
7
|
-
const { resolveDbScope, normalizeOwnedTables, isSystemTable, resolveTablePrefix } = require('../utils/dbIdentity');
|
|
7
|
+
const { resolveDbScope, normalizeOwnedTables, isSystemTable, resolveTablePrefix, filterOwnedTables } = require('../utils/dbIdentity');
|
|
8
8
|
const { assertReadOnlyQuerySql } = require('../utils/sqlQueryGuard');
|
|
9
9
|
|
|
10
10
|
module.exports = fp(async (fastify, options) => {
|
|
@@ -83,15 +83,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
83
83
|
return { table, columns, primaryKey };
|
|
84
84
|
};
|
|
85
85
|
|
|
86
|
-
const getOwnedTables = (app, allTables) => {
|
|
87
|
-
const scope = resolveDbScope(app.env || {}, app.options || {});
|
|
88
|
-
const existing = new Set(allTables);
|
|
89
|
-
if (scope === 'dedicated') {
|
|
90
|
-
return allTables.filter(t => !isSystemTable(t));
|
|
91
|
-
}
|
|
92
|
-
const owned = normalizeOwnedTables(app.options?.ownedTables);
|
|
93
|
-
return owned.filter(t => existing.has(t));
|
|
94
|
-
};
|
|
86
|
+
const getOwnedTables = (app, allTables) => filterOwnedTables({ appEnv: app.env || {}, ownedTables: app.options?.ownedTables, allTables });
|
|
95
87
|
|
|
96
88
|
const assertTableOwned = (app, allTables, table) => {
|
|
97
89
|
const owned = getOwnedTables(app, allTables);
|
package/libs/utils/dbIdentity.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const path = require('node:path');
|
|
2
|
-
const {
|
|
2
|
+
const { isAppDbEnabled } = require('./env');
|
|
3
3
|
|
|
4
4
|
const SYSTEM_TABLES = new Set(['_fs_sql_migrations']);
|
|
5
5
|
|
|
@@ -69,12 +69,8 @@ const assertDefaultAppDbSeparated = (defaultAppDb, hostSequelize) => {
|
|
|
69
69
|
}
|
|
70
70
|
};
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return options.dbScope;
|
|
75
|
-
}
|
|
76
|
-
return hasAppDbConfig(appEnv) ? 'dedicated' : 'shared';
|
|
77
|
-
};
|
|
72
|
+
// 与 startProcess 注入的连接一致:只看 env(独立库配置 + DB_DEDICATED 开关)。options.dbScope 只是认领表时写入的缓存,env 变化后会过期
|
|
73
|
+
const resolveDbScope = (appEnv = {}) => (isAppDbEnabled(appEnv) ? 'dedicated' : 'shared');
|
|
78
74
|
|
|
79
75
|
const normalizeOwnedTables = tables => [...new Set((Array.isArray(tables) ? tables : []).map(t => String(t || '').trim()).filter(Boolean))];
|
|
80
76
|
|
|
@@ -104,8 +100,21 @@ const resolveTablePrefix = (appName, appEnv = {}) => {
|
|
|
104
100
|
return buildTablePrefix(appName);
|
|
105
101
|
};
|
|
106
102
|
|
|
103
|
+
/**
|
|
104
|
+
* 共享库:只认登记过的表;独立库:库内业务表全归本应用,但显式配了 DB_TABLE_PREFIX 说明该库与其它系统共用,只认本前缀的表
|
|
105
|
+
*/
|
|
106
|
+
const filterOwnedTables = ({ appEnv = {}, ownedTables, allTables = [] }) => {
|
|
107
|
+
if (resolveDbScope(appEnv) === 'dedicated') {
|
|
108
|
+
const prefix = String(appEnv.DB_TABLE_PREFIX || '').trim();
|
|
109
|
+
return allTables.filter(t => !isSystemTable(t) && (!prefix || String(t).startsWith(prefix)));
|
|
110
|
+
}
|
|
111
|
+
const existing = new Set(allTables);
|
|
112
|
+
return normalizeOwnedTables(ownedTables).filter(t => existing.has(t));
|
|
113
|
+
};
|
|
114
|
+
|
|
107
115
|
module.exports = {
|
|
108
116
|
SYSTEM_TABLES,
|
|
117
|
+
filterOwnedTables,
|
|
109
118
|
fingerprintDbConfig,
|
|
110
119
|
fingerprintFromEnv,
|
|
111
120
|
assertDefaultAppDbSeparated,
|