@kne/fastify-app-manager 0.1.10 → 0.1.12
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 +152 -18
- package/index.js +2 -0
- package/libs/controllers/app.js +116 -16
- package/libs/models/appVersion.js +15 -0
- package/libs/services/app.js +479 -42
- package/libs/services/bootstrap.js +68 -5
- package/libs/services/migration.js +34 -46
- package/libs/utils/deployProgress.js +66 -0
- package/libs/utils/env.js +1 -0
- package/libs/utils/envSchema.js +105 -0
- package/libs/utils/validatePackage.js +13 -1
- package/libs/utils/version.js +10 -4
- package/libs/utils/versionSql.js +33 -0
- package/package.json +2 -1
package/libs/services/app.js
CHANGED
|
@@ -3,14 +3,30 @@ 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 {
|
|
6
|
+
const {
|
|
7
|
+
mergeEnv,
|
|
8
|
+
maskEnvForResponse,
|
|
9
|
+
applyEnvPatch,
|
|
10
|
+
mergePm2Config,
|
|
11
|
+
normalizeSecretKeys,
|
|
12
|
+
collectSecretKeys,
|
|
13
|
+
isSystemEnvKey,
|
|
14
|
+
isSecretKey,
|
|
15
|
+
omitSystemEnv,
|
|
16
|
+
SECRET_MASK,
|
|
17
|
+
SYSTEM_ENV_KEYS,
|
|
18
|
+
DB_CONNECTION_KEYS,
|
|
19
|
+
DB_DEDICATED_KEY
|
|
20
|
+
} = require('../utils/env');
|
|
21
|
+
const { readEnvSchema, validateEnv, ENV_SCHEMA_FILE } = require('../utils/envSchema');
|
|
7
22
|
const { prepareVersionArtifact } = require('../utils/version');
|
|
8
23
|
const { injectEntryHtml } = require('../utils/entryInject');
|
|
9
24
|
const { ensureLogFiles, readLogTail, readLastLines, appendLog, countLines } = require('../utils/logFiles');
|
|
10
25
|
const { currentFileName, isCurrentFileName, resolveLogFile, listLogFiles } = require('../utils/logRotate');
|
|
11
26
|
const { toIsoInTz, convertLogText } = require('../utils/logTime');
|
|
12
27
|
const { runSqlMigrations, MIGRATION_ACTIONS } = require('../utils/migrate');
|
|
13
|
-
const {
|
|
28
|
+
const { readSqlDir, writeSqlDir } = require('../utils/versionSql');
|
|
29
|
+
const { createDeployProgress } = require('../utils/deployProgress');
|
|
14
30
|
const pm2Util = require('../utils/pm2');
|
|
15
31
|
|
|
16
32
|
const NAME_RE = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
@@ -197,7 +213,9 @@ module.exports = fp(async (fastify, options) => {
|
|
|
197
213
|
const app = await getByName(name);
|
|
198
214
|
// 密钥类型不可撤销,只随变量删除而移除
|
|
199
215
|
const secretEnvKeys = normalizeSecretKeys([...resolveSecretEnvKeys(app), ...(nextSecretKeys || [])]);
|
|
200
|
-
|
|
216
|
+
// PORT 由端口池分配,启动时总会覆盖,不接受外部写入
|
|
217
|
+
const { PORT, ...envPatch } = env || {};
|
|
218
|
+
const nextEnv = applyEnvPatch(app.env || {}, envPatch, Object.assign({}, options, { secretEnvKeys }));
|
|
201
219
|
const cleanedSecrets = secretEnvKeys.filter(key => !isSystemEnvKey(key, options.systemEnvKeys) && Object.prototype.hasOwnProperty.call(nextEnv, key));
|
|
202
220
|
await app.update({
|
|
203
221
|
env: nextEnv,
|
|
@@ -250,7 +268,60 @@ module.exports = fp(async (fastify, options) => {
|
|
|
250
268
|
return toPublicApp(app);
|
|
251
269
|
};
|
|
252
270
|
|
|
253
|
-
const
|
|
271
|
+
const versionSqlDir = ver => path.join(ver.artifactPath, 'server', ver.migrationPath || options.sqlPath || 'sql');
|
|
272
|
+
|
|
273
|
+
const hasArtifact = ver => fs.pathExists(path.join(ver.artifactPath, 'server'));
|
|
274
|
+
|
|
275
|
+
/** SQL 以数据库为准;旧版本行(migrationFiles 为空)首次访问时从版本目录回填 */
|
|
276
|
+
const getMigrationFiles = async ver => {
|
|
277
|
+
if (Array.isArray(ver.migrationFiles)) {
|
|
278
|
+
return ver.migrationFiles;
|
|
279
|
+
}
|
|
280
|
+
if (!(await hasArtifact(ver))) {
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
283
|
+
const files = await readSqlDir(versionSqlDir(ver));
|
|
284
|
+
await ver.update({ migrationFiles: files, hasMigration: files.length > 0 });
|
|
285
|
+
return files;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/** 写库,版本目录在磁盘上时同步覆盖其 .sql */
|
|
289
|
+
const saveMigrationFiles = async (ver, files) => {
|
|
290
|
+
const sorted = [...files].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
291
|
+
await ver.update({ migrationFiles: sorted, hasMigration: sorted.length > 0 });
|
|
292
|
+
if (await hasArtifact(ver)) {
|
|
293
|
+
await writeSqlDir(versionSqlDir(ver), sorted);
|
|
294
|
+
}
|
|
295
|
+
return sorted;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const toPublicVersion = (row, files = row.migrationFiles) => {
|
|
299
|
+
const { migrationFiles, envSchema, ...json } = row.toJSON();
|
|
300
|
+
if (Array.isArray(files) && files.length) {
|
|
301
|
+
json.sqlFiles = files.map(item => item.name);
|
|
302
|
+
}
|
|
303
|
+
json.hasEnvSchema = !!envSchema;
|
|
304
|
+
return json;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/** schema 以数据库为准;旧版本行(envSchema 为空)且版本目录在磁盘时从目录回填 */
|
|
308
|
+
const getVersionEnvSchema = async ver => {
|
|
309
|
+
if (ver.envSchema) {
|
|
310
|
+
return ver.envSchema;
|
|
311
|
+
}
|
|
312
|
+
if (!(await hasArtifact(ver))) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
const schema = await readEnvSchema(path.join(ver.artifactPath, 'server'));
|
|
317
|
+
await ver.update({ envSchema: schema });
|
|
318
|
+
return schema;
|
|
319
|
+
} catch (e) {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const uploadVersion = async ({ name, version, label, zipBuffer, fileId }) => {
|
|
254
325
|
if (!version) {
|
|
255
326
|
httpError(400, 'version is required');
|
|
256
327
|
}
|
|
@@ -270,7 +341,10 @@ module.exports = fp(async (fastify, options) => {
|
|
|
270
341
|
// Prepare outside the version table so failed or in-progress uploads never appear in the version list.
|
|
271
342
|
const versionsDir = path.join(app.rootPath, 'versions');
|
|
272
343
|
const stagingPath = path.join(versionsDir, `.staging-${crypto.randomUUID()}`);
|
|
344
|
+
// 有宿主文件的版本只校验不常驻磁盘,部署时再解压;没有的只能保留目录
|
|
345
|
+
const keepArtifact = !fileId;
|
|
273
346
|
let prepared;
|
|
347
|
+
let migrationFiles;
|
|
274
348
|
try {
|
|
275
349
|
prepared = await prepareVersionArtifact({
|
|
276
350
|
zipBuffer,
|
|
@@ -278,8 +352,10 @@ module.exports = fp(async (fastify, options) => {
|
|
|
278
352
|
maxZipSize: options.maxZipSize,
|
|
279
353
|
maxZipEntries: options.maxZipEntries,
|
|
280
354
|
npmInstallTimeoutMs: options.npmInstallTimeoutMs,
|
|
281
|
-
sqlPath: options.sqlPath
|
|
355
|
+
sqlPath: options.sqlPath,
|
|
356
|
+
install: keepArtifact
|
|
282
357
|
});
|
|
358
|
+
migrationFiles = await readSqlDir(path.join(stagingPath, 'server', prepared.migrationPath));
|
|
283
359
|
} catch (e) {
|
|
284
360
|
await fs.remove(stagingPath).catch(() => {});
|
|
285
361
|
httpError(400, e.message);
|
|
@@ -287,26 +363,35 @@ module.exports = fp(async (fastify, options) => {
|
|
|
287
363
|
|
|
288
364
|
let artifactPath = null;
|
|
289
365
|
try {
|
|
290
|
-
|
|
366
|
+
const result = await models.appVersion.sequelize.transaction(async transaction => {
|
|
291
367
|
const versionRow = await models.appVersion.create(
|
|
292
368
|
{
|
|
293
369
|
appName: name,
|
|
294
370
|
version,
|
|
295
371
|
label: label || null,
|
|
296
372
|
artifactPath: stagingPath,
|
|
297
|
-
hasMigration:
|
|
298
|
-
migrationPath: prepared.migrationPath
|
|
373
|
+
hasMigration: migrationFiles.length > 0,
|
|
374
|
+
migrationPath: prepared.migrationPath,
|
|
375
|
+
fileId: fileId ? String(fileId) : null,
|
|
376
|
+
migrationFiles,
|
|
377
|
+
envSchema: prepared.envSchema
|
|
299
378
|
},
|
|
300
379
|
{ transaction }
|
|
301
380
|
);
|
|
302
381
|
artifactPath = path.join(versionsDir, String(versionRow.id));
|
|
303
|
-
|
|
382
|
+
if (keepArtifact) {
|
|
383
|
+
await fs.move(stagingPath, artifactPath);
|
|
384
|
+
}
|
|
304
385
|
await versionRow.update({ artifactPath }, { transaction });
|
|
305
|
-
return versionRow
|
|
386
|
+
return toPublicVersion(versionRow);
|
|
306
387
|
});
|
|
388
|
+
if (!keepArtifact) {
|
|
389
|
+
await fs.remove(stagingPath).catch(() => {});
|
|
390
|
+
}
|
|
391
|
+
return result;
|
|
307
392
|
} catch (e) {
|
|
308
393
|
await fs.remove(stagingPath).catch(() => {});
|
|
309
|
-
if (artifactPath) {
|
|
394
|
+
if (artifactPath && keepArtifact) {
|
|
310
395
|
await fs.remove(artifactPath).catch(() => {});
|
|
311
396
|
}
|
|
312
397
|
if (e.name === 'SequelizeUniqueConstraintError') {
|
|
@@ -326,16 +411,52 @@ module.exports = fp(async (fastify, options) => {
|
|
|
326
411
|
});
|
|
327
412
|
const pageData = await Promise.all(
|
|
328
413
|
rows.map(async row => {
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
json.sqlFiles = await listSqlFiles(path.join(json.artifactPath, 'server', json.migrationPath || 'sql'));
|
|
332
|
-
}
|
|
333
|
-
return json;
|
|
414
|
+
const files = await getMigrationFiles(row);
|
|
415
|
+
return toPublicVersion(row, files);
|
|
334
416
|
})
|
|
335
417
|
);
|
|
336
418
|
return { pageData, totalCount: count };
|
|
337
419
|
};
|
|
338
420
|
|
|
421
|
+
const listArtifactFiles = async (root, dir = '') => {
|
|
422
|
+
const files = [];
|
|
423
|
+
for (const entry of await fs.readdir(path.join(root, dir), { withFileTypes: true })) {
|
|
424
|
+
const rel = dir ? `${dir}/${entry.name}` : entry.name;
|
|
425
|
+
// 部署时装的依赖与复制进 server 的 build 都不属于原始包
|
|
426
|
+
if (rel === 'server/node_modules' || rel === 'server/build') {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (entry.isDirectory()) {
|
|
430
|
+
files.push(...(await listArtifactFiles(root, rel)));
|
|
431
|
+
} else if (entry.isFile()) {
|
|
432
|
+
files.push(rel);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return files;
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
/** 有 fileId 时返回原始 zip;否则从版本目录还原包内容(不含 server/node_modules、server/build) */
|
|
439
|
+
const resolveVersionPackage = async ({ name, versionId }) => {
|
|
440
|
+
await getByName(name);
|
|
441
|
+
const ver = await models.appVersion.findByPk(versionId);
|
|
442
|
+
if (!ver || ver.appName !== name) {
|
|
443
|
+
httpError(404, 'version not found');
|
|
444
|
+
}
|
|
445
|
+
const fileName = `${name}-${ver.version}.zip`;
|
|
446
|
+
if (ver.fileId && typeof options.resolveVersionFile === 'function') {
|
|
447
|
+
try {
|
|
448
|
+
const buffer = await options.resolveVersionFile({ fileId: ver.fileId, name, version: ver.version });
|
|
449
|
+
return { fileName, buffer };
|
|
450
|
+
} catch (e) {
|
|
451
|
+
fastify.log.warn({ err: e, app: name, version: ver.version }, 'resolve version file failed, fallback to artifact');
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (!(await fs.pathExists(path.join(ver.artifactPath, 'server')))) {
|
|
455
|
+
httpError(404, 'version package not found');
|
|
456
|
+
}
|
|
457
|
+
return { fileName, artifactPath: ver.artifactPath, files: await listArtifactFiles(ver.artifactPath) };
|
|
458
|
+
};
|
|
459
|
+
|
|
339
460
|
const healthCheck = async (port, { timeoutMs, intervalMs, healthPath }) => {
|
|
340
461
|
const started = Date.now();
|
|
341
462
|
const url = `http://127.0.0.1:${port}${healthPath || '/'}`;
|
|
@@ -365,6 +486,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
365
486
|
|
|
366
487
|
/** 同一 app 并发同步合并为一次,避免 restart/reconcile 竞态 */
|
|
367
488
|
const statusSyncInflight = new Map();
|
|
489
|
+
const deployProgress = createDeployProgress();
|
|
368
490
|
|
|
369
491
|
/**
|
|
370
492
|
* 根据 PM2 真实进程状态 + HTTP 健康检查,异步写回 DB status。
|
|
@@ -459,11 +581,14 @@ module.exports = fp(async (fastify, options) => {
|
|
|
459
581
|
|
|
460
582
|
const finishDeployAsync = async appId => syncRealStatus(appId, { recoverIfMissing: false });
|
|
461
583
|
|
|
462
|
-
const scheduleStatusSync = (appId, opts) => {
|
|
584
|
+
const scheduleStatusSync = (appId, { onSettled, ...opts } = {}) => {
|
|
463
585
|
setImmediate(() => {
|
|
464
|
-
syncRealStatus(appId, opts)
|
|
465
|
-
|
|
466
|
-
|
|
586
|
+
syncRealStatus(appId, opts)
|
|
587
|
+
.catch(err => {
|
|
588
|
+
fastify.log.warn({ err, appId }, 'syncRealStatus failed');
|
|
589
|
+
return { status: 'error', message: err.message };
|
|
590
|
+
})
|
|
591
|
+
.then(result => onSettled && onSettled(result));
|
|
467
592
|
});
|
|
468
593
|
};
|
|
469
594
|
|
|
@@ -472,27 +597,27 @@ module.exports = fp(async (fastify, options) => {
|
|
|
472
597
|
return syncRealStatus(app.id, { recoverIfMissing });
|
|
473
598
|
};
|
|
474
599
|
|
|
475
|
-
const syncAllStatuses = async ({ recoverIfMissing = true } = {}) => {
|
|
600
|
+
const syncAllStatuses = async ({ recoverIfMissing = true, excludeIds = [] } = {}) => {
|
|
476
601
|
const apps = await models.app.findAll({
|
|
477
602
|
where: {
|
|
478
603
|
status: ['running', 'deploying', 'stopped', 'error']
|
|
479
604
|
}
|
|
480
605
|
});
|
|
606
|
+
const excluded = new Set(excludeIds.map(String));
|
|
481
607
|
const results = [];
|
|
482
608
|
for (const app of apps) {
|
|
609
|
+
if (excluded.has(String(app.id))) {
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
483
612
|
const shouldRecover = recoverIfMissing && (app.status === 'running' || app.status === 'deploying');
|
|
484
613
|
results.push(await syncRealStatus(app.id, { recoverIfMissing: shouldRecover }));
|
|
485
614
|
}
|
|
486
615
|
return results.filter(Boolean);
|
|
487
616
|
};
|
|
488
617
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
if (!version) {
|
|
492
|
-
throw new Error('current version missing');
|
|
493
|
-
}
|
|
618
|
+
/** 子进程最终环境变量:宿主透传 → 托管库 → 应用变量 → PORT → resolveSystemEnv;顺带持久化表前缀与系统变量 */
|
|
619
|
+
const buildProcessEnv = async (app, version) => {
|
|
494
620
|
const serverDir = path.join(version.artifactPath, 'server');
|
|
495
|
-
const { outFile, errFile } = await ensureLogFiles(app.rootPath);
|
|
496
621
|
const env = mergeEnv({
|
|
497
622
|
passthroughEnvKeys: options.passthroughEnvKeys,
|
|
498
623
|
appEnv: app.env,
|
|
@@ -511,6 +636,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
511
636
|
|
|
512
637
|
if (typeof options.resolveSystemEnv === 'function') {
|
|
513
638
|
const systemEnv = await options.resolveSystemEnv({ app, version, serverDir });
|
|
639
|
+
const injectedKeys = [];
|
|
514
640
|
if (systemEnv && typeof systemEnv === 'object') {
|
|
515
641
|
const persisted = {};
|
|
516
642
|
for (const [key, value] of Object.entries(systemEnv)) {
|
|
@@ -518,6 +644,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
518
644
|
continue;
|
|
519
645
|
}
|
|
520
646
|
env[key] = String(value);
|
|
647
|
+
injectedKeys.push(key);
|
|
521
648
|
if ((options.systemEnvKeys || []).includes(key) && app.env?.[key] !== env[key]) {
|
|
522
649
|
persisted[key] = env[key];
|
|
523
650
|
}
|
|
@@ -527,7 +654,143 @@ module.exports = fp(async (fastify, options) => {
|
|
|
527
654
|
await app.reload();
|
|
528
655
|
}
|
|
529
656
|
}
|
|
657
|
+
// 记下本次由系统注入的键,供环境变量声明接口隐藏(如未配置 ORIGIN 时由宿主补上的值)
|
|
658
|
+
const recorded = app.options?.injectedEnvKeys || [];
|
|
659
|
+
if (recorded.length !== injectedKeys.length || recorded.some(key => !injectedKeys.includes(key))) {
|
|
660
|
+
await app.update({ options: Object.assign({}, app.options || {}, { injectedEnvKeys: injectedKeys }) });
|
|
661
|
+
await app.reload();
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return env;
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
const envSchemaMissingError = () => {
|
|
668
|
+
const err = new Error(`该版本缺少 server/${ENV_SCHEMA_FILE},请补充后重新上传`);
|
|
669
|
+
err.envErrors = [];
|
|
670
|
+
return err;
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
/** 按版本的 env.schema.json 校验最终环境变量,不通过时抛错(err.envErrors 为 [{ key, message }]) */
|
|
674
|
+
const assertEnvValid = async (app, version, env) => {
|
|
675
|
+
const schema = await getVersionEnvSchema(version);
|
|
676
|
+
if (!schema) {
|
|
677
|
+
throw envSchemaMissingError();
|
|
678
|
+
}
|
|
679
|
+
const errors = validateEnv(schema, env);
|
|
680
|
+
if (errors.length) {
|
|
681
|
+
const injected = listInjectedEnvKeys(app);
|
|
682
|
+
const detail = errors.map(({ key, message }) => `${key}${injected.has(key) ? '(应用中心注入)' : ''} ${message}`).join(';');
|
|
683
|
+
const err = new Error(`环境变量不符合 ${ENV_SCHEMA_FILE}:${detail}`);
|
|
684
|
+
err.envErrors = errors;
|
|
685
|
+
throw err;
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
/** 应用中心自动注入或在其它页签维护的变量名:环境变量管理中不展示 */
|
|
690
|
+
const listInjectedEnvKeys = app =>
|
|
691
|
+
new Set([
|
|
692
|
+
'PORT',
|
|
693
|
+
...SYSTEM_ENV_KEYS,
|
|
694
|
+
...(options.systemEnvKeys || []),
|
|
695
|
+
...(options.passthroughEnvKeys || []),
|
|
696
|
+
// 托管库连接由宿主注入,自有库在数据页签配置
|
|
697
|
+
...DB_CONNECTION_KEYS,
|
|
698
|
+
DB_DEDICATED_KEY,
|
|
699
|
+
...(app.options?.injectedEnvKeys || [])
|
|
700
|
+
]);
|
|
701
|
+
|
|
702
|
+
const resolveDeclarationVersion = async (app, versionId) => {
|
|
703
|
+
if (versionId) {
|
|
704
|
+
const ver = await models.appVersion.findByPk(versionId);
|
|
705
|
+
if (!ver || ver.appName !== app.name) {
|
|
706
|
+
httpError(404, 'version not found');
|
|
707
|
+
}
|
|
708
|
+
return ver;
|
|
709
|
+
}
|
|
710
|
+
if (app.currentVersionId) {
|
|
711
|
+
const ver = await models.appVersion.findByPk(app.currentVersionId);
|
|
712
|
+
if (ver) {
|
|
713
|
+
return ver;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
return models.appVersion.findOne({ where: { appName: app.name }, order: [['createdAt', 'DESC']] });
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* 环境变量声明:declared 为 schema 中需要用户填写的变量,others 为已配置但未声明的变量;
|
|
721
|
+
* 两者都不含应用中心注入的变量,密钥值掩码
|
|
722
|
+
*/
|
|
723
|
+
const getEnvDeclaration = async ({ name, versionId } = {}) => {
|
|
724
|
+
const app = await getByName(name);
|
|
725
|
+
const ver = await resolveDeclarationVersion(app, versionId);
|
|
726
|
+
const schema = ver ? await getVersionEnvSchema(ver) : null;
|
|
727
|
+
const injected = listInjectedEnvKeys(app);
|
|
728
|
+
const appEnv = omitSystemEnv(app.env || {}, options.systemEnvKeys);
|
|
729
|
+
const secretOpts = Object.assign({}, options, { secretEnvKeys: resolveSecretEnvKeys(app) });
|
|
730
|
+
const isSecret = (key, prop) => !!prop?.writeOnly || isSecretKey(key, options.secretEnvKeyPattern, secretOpts.secretEnvKeys);
|
|
731
|
+
const maskValue = (key, prop) => {
|
|
732
|
+
const value = appEnv[key];
|
|
733
|
+
if (value == null || value === '') {
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
return isSecret(key, prop) ? SECRET_MASK : value;
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
const properties = schema?.properties || {};
|
|
740
|
+
const required = new Set(schema?.required || []);
|
|
741
|
+
const errorMap = new Map();
|
|
742
|
+
if (schema) {
|
|
743
|
+
const previewEnv = mergeEnv({
|
|
744
|
+
passthroughEnvKeys: options.passthroughEnvKeys,
|
|
745
|
+
appEnv: app.env,
|
|
746
|
+
port: app.port,
|
|
747
|
+
defaultAppDb: options.defaultAppDb,
|
|
748
|
+
appName: app.name
|
|
749
|
+
});
|
|
750
|
+
validateEnv(schema, previewEnv)
|
|
751
|
+
.filter(item => !injected.has(item.key))
|
|
752
|
+
.forEach(item => errorMap.set(item.key, item.message));
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const declared = Object.entries(properties)
|
|
756
|
+
.filter(([key]) => !injected.has(key))
|
|
757
|
+
.map(([key, prop]) => ({
|
|
758
|
+
key,
|
|
759
|
+
type: prop?.type || null,
|
|
760
|
+
required: required.has(key),
|
|
761
|
+
default: prop?.default === undefined ? null : prop.default,
|
|
762
|
+
description: prop?.description || prop?.title || null,
|
|
763
|
+
enum: Array.isArray(prop?.enum) ? prop.enum : null,
|
|
764
|
+
secret: isSecret(key, prop),
|
|
765
|
+
configured: appEnv[key] != null && appEnv[key] !== '',
|
|
766
|
+
value: maskValue(key, prop),
|
|
767
|
+
error: errorMap.get(key) || null
|
|
768
|
+
}));
|
|
769
|
+
|
|
770
|
+
const others = Object.keys(appEnv)
|
|
771
|
+
.filter(key => !injected.has(key) && !Object.prototype.hasOwnProperty.call(properties, key))
|
|
772
|
+
.map(key => ({ key, secret: isSecret(key), value: maskValue(key) }));
|
|
773
|
+
|
|
774
|
+
return {
|
|
775
|
+
name: app.name,
|
|
776
|
+
versionId: ver ? String(ver.id) : null,
|
|
777
|
+
version: ver ? ver.version : null,
|
|
778
|
+
hasSchema: !!schema,
|
|
779
|
+
valid: !!schema && errorMap.size === 0,
|
|
780
|
+
declared,
|
|
781
|
+
others
|
|
782
|
+
};
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
const startProcess = async (app, { env: preparedEnv } = {}) => {
|
|
786
|
+
const version = await models.appVersion.findByPk(app.currentVersionId);
|
|
787
|
+
if (!version) {
|
|
788
|
+
throw new Error('current version missing');
|
|
530
789
|
}
|
|
790
|
+
const serverDir = path.join(version.artifactPath, 'server');
|
|
791
|
+
const env = preparedEnv || (await buildProcessEnv(app, version));
|
|
792
|
+
await assertEnvValid(app, version, env);
|
|
793
|
+
const { outFile, errFile } = await ensureLogFiles(app.rootPath);
|
|
531
794
|
|
|
532
795
|
const pathBase = mountPrefix(app.name);
|
|
533
796
|
// Domain-first: inject `/` so custom Host SPA works; path mode then relies on gateway rewrite fallback.
|
|
@@ -551,7 +814,119 @@ module.exports = fp(async (fastify, options) => {
|
|
|
551
814
|
});
|
|
552
815
|
};
|
|
553
816
|
|
|
554
|
-
const
|
|
817
|
+
const linkCurrent = async (app, artifactPath) => {
|
|
818
|
+
const currentLink = path.join(app.rootPath, 'current');
|
|
819
|
+
await fs.remove(currentLink).catch(() => {});
|
|
820
|
+
await fs.symlink(artifactPath, currentLink).catch(async () => {
|
|
821
|
+
// Windows fallback: copy marker file
|
|
822
|
+
await fs.writeFile(path.join(app.rootPath, 'current-path.txt'), artifactPath);
|
|
823
|
+
});
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
/** 只做 fs 检查:返回当前版本目录(artifactPath/server)缺失的应用 */
|
|
827
|
+
const listMissingArtifacts = async () => {
|
|
828
|
+
const { Op } = fastify.sequelize.Sequelize;
|
|
829
|
+
const apps = await models.app.findAll({ where: { currentVersionId: { [Op.ne]: null } } });
|
|
830
|
+
const missing = [];
|
|
831
|
+
for (const app of apps) {
|
|
832
|
+
const ver = await models.appVersion.findByPk(app.currentVersionId);
|
|
833
|
+
if (ver && !(await fs.pathExists(path.join(ver.artifactPath, 'server')))) {
|
|
834
|
+
missing.push({ app, ver });
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return missing;
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
const canResolveFile = ver => !!ver.fileId && typeof options.resolveVersionFile === 'function';
|
|
841
|
+
|
|
842
|
+
/** 版本目录不在磁盘时按 fileId 取回 zip 解压并装依赖;再以数据库中的 SQL 覆盖目录内 .sql */
|
|
843
|
+
const materializeArtifact = async (app, ver, { onProgress } = {}) => {
|
|
844
|
+
if (await hasArtifact(ver)) {
|
|
845
|
+
onProgress && onProgress('reuse');
|
|
846
|
+
} else {
|
|
847
|
+
if (!canResolveFile(ver)) {
|
|
848
|
+
throw new Error(`version ${ver.version} artifact missing and cannot be restored without fileId`);
|
|
849
|
+
}
|
|
850
|
+
const stagingPath = path.join(app.rootPath, 'versions', `.staging-${crypto.randomUUID()}`);
|
|
851
|
+
try {
|
|
852
|
+
onProgress && onProgress('download');
|
|
853
|
+
const zipBuffer = await options.resolveVersionFile({ fileId: ver.fileId, name: app.name, version: ver.version });
|
|
854
|
+
await prepareVersionArtifact({
|
|
855
|
+
zipBuffer,
|
|
856
|
+
artifactPath: stagingPath,
|
|
857
|
+
maxZipSize: options.maxZipSize,
|
|
858
|
+
maxZipEntries: options.maxZipEntries,
|
|
859
|
+
npmInstallTimeoutMs: options.npmInstallTimeoutMs,
|
|
860
|
+
sqlPath: options.sqlPath,
|
|
861
|
+
// 老版本可能没有 env.schema.json:照常还原目录,由启动前的环境变量校验给出提示
|
|
862
|
+
requireEnvSchema: false,
|
|
863
|
+
onProgress
|
|
864
|
+
});
|
|
865
|
+
await fs.move(stagingPath, ver.artifactPath, { overwrite: true });
|
|
866
|
+
} catch (e) {
|
|
867
|
+
await fs.remove(stagingPath).catch(() => {});
|
|
868
|
+
throw e;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
await writeSqlDir(versionSqlDir(ver), await getMigrationFiles(ver));
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
/** 删除非当前版本中可从宿主文件重建的目录;删前先把 SQL 回填进库 */
|
|
875
|
+
const pruneVersionArtifacts = async app => {
|
|
876
|
+
const versions = await models.appVersion.findAll({ where: { appName: app.name } });
|
|
877
|
+
const removed = [];
|
|
878
|
+
for (const ver of versions) {
|
|
879
|
+
if (String(ver.id) === String(app.currentVersionId) || !canResolveFile(ver) || !(await fs.pathExists(ver.artifactPath))) {
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
await getMigrationFiles(ver);
|
|
883
|
+
await fs.remove(ver.artifactPath);
|
|
884
|
+
removed.push(ver.version);
|
|
885
|
+
}
|
|
886
|
+
if (removed.length) {
|
|
887
|
+
fastify.log.info({ app: app.name, versions: removed }, 'pruned non-current version artifacts');
|
|
888
|
+
}
|
|
889
|
+
return removed;
|
|
890
|
+
};
|
|
891
|
+
|
|
892
|
+
const pruneAllVersionArtifacts = async () => {
|
|
893
|
+
const results = [];
|
|
894
|
+
for (const app of await models.app.findAll()) {
|
|
895
|
+
try {
|
|
896
|
+
results.push({ name: app.name, removed: await pruneVersionArtifacts(app) });
|
|
897
|
+
} catch (e) {
|
|
898
|
+
fastify.log.warn({ err: e, app: app.name }, 'prune version artifacts failed');
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
return results;
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
/** 补回当前版本目录并恢复 current 链接;不跑 SQL 迁移 */
|
|
905
|
+
const restoreArtifact = async ({ app, ver }) => {
|
|
906
|
+
if (!canResolveFile(ver)) {
|
|
907
|
+
fastify.log.warn({ app: app.name, version: ver.version }, 'current version artifact missing and cannot be restored without fileId');
|
|
908
|
+
return { name: app.name, version: ver.version, action: 'skipped', reason: 'no fileId' };
|
|
909
|
+
}
|
|
910
|
+
try {
|
|
911
|
+
await materializeArtifact(app, ver);
|
|
912
|
+
await linkCurrent(app, ver.artifactPath);
|
|
913
|
+
fastify.log.info({ app: app.name, version: ver.version }, 'current version artifact restored from file');
|
|
914
|
+
return { name: app.name, version: ver.version, action: 'restored' };
|
|
915
|
+
} catch (e) {
|
|
916
|
+
fastify.log.warn({ err: e, app: app.name, version: ver.version }, 'restore current version artifact failed');
|
|
917
|
+
return { name: app.name, version: ver.version, action: 'failed', reason: e.message };
|
|
918
|
+
}
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
const restoreMissingArtifacts = async () => {
|
|
922
|
+
const results = [];
|
|
923
|
+
for (const item of await listMissingArtifacts()) {
|
|
924
|
+
results.push(await restoreArtifact(item));
|
|
925
|
+
}
|
|
926
|
+
return results;
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
const deploy = async ({ name, versionId, version, runMigration = true, migrations, progressId }) => {
|
|
555
930
|
const app = await getByName(name);
|
|
556
931
|
let ver = null;
|
|
557
932
|
if (versionId) {
|
|
@@ -563,21 +938,42 @@ module.exports = fp(async (fastify, options) => {
|
|
|
563
938
|
httpError(404, 'version not found');
|
|
564
939
|
}
|
|
565
940
|
|
|
941
|
+
const progress = deployProgress.begin(app.name, progressId || crypto.randomUUID());
|
|
942
|
+
let step = 'package';
|
|
943
|
+
progress.report(step, 'running');
|
|
944
|
+
try {
|
|
945
|
+
await materializeArtifact(app, ver, { onProgress: detail => progress.report('package', 'running', { detail }) });
|
|
946
|
+
} catch (e) {
|
|
947
|
+
progress.report(step, 'error', { message: e.message });
|
|
948
|
+
httpError(400, e.message);
|
|
949
|
+
}
|
|
950
|
+
progress.report(step, 'done');
|
|
951
|
+
|
|
952
|
+
// 变量不合规时不切换版本、不执行迁移、不启动进程
|
|
953
|
+
step = 'env';
|
|
954
|
+
progress.report(step, 'running');
|
|
955
|
+
let processEnv;
|
|
956
|
+
try {
|
|
957
|
+
processEnv = await buildProcessEnv(app, ver);
|
|
958
|
+
await assertEnvValid(app, ver, processEnv);
|
|
959
|
+
} catch (e) {
|
|
960
|
+
progress.report(step, 'error', { message: e.message });
|
|
961
|
+
httpError(400, e.message);
|
|
962
|
+
}
|
|
963
|
+
progress.report(step, 'done');
|
|
964
|
+
|
|
566
965
|
await app.update({
|
|
567
966
|
status: 'deploying',
|
|
568
967
|
currentVersionId: String(ver.id),
|
|
569
968
|
message: null
|
|
570
969
|
});
|
|
571
970
|
|
|
572
|
-
|
|
573
|
-
await fs.remove(currentLink).catch(() => {});
|
|
574
|
-
await fs.symlink(ver.artifactPath, currentLink).catch(async () => {
|
|
575
|
-
// Windows fallback: copy marker file
|
|
576
|
-
await fs.writeFile(path.join(app.rootPath, 'current-path.txt'), ver.artifactPath);
|
|
577
|
-
});
|
|
971
|
+
await linkCurrent(app, ver.artifactPath);
|
|
578
972
|
|
|
579
973
|
try {
|
|
974
|
+
step = 'migrate';
|
|
580
975
|
if (runMigration && options.migrateBeforeStart && ver.hasMigration) {
|
|
976
|
+
progress.report(step, 'running');
|
|
581
977
|
const env = mergeEnv({
|
|
582
978
|
passthroughEnvKeys: options.passthroughEnvKeys,
|
|
583
979
|
appEnv: app.env,
|
|
@@ -612,25 +1008,39 @@ module.exports = fp(async (fastify, options) => {
|
|
|
612
1008
|
} catch (e) {
|
|
613
1009
|
// ignore ownership merge failures
|
|
614
1010
|
}
|
|
1011
|
+
progress.report(step, 'done');
|
|
1012
|
+
} else {
|
|
1013
|
+
progress.report(step, 'skipped');
|
|
615
1014
|
}
|
|
616
1015
|
|
|
617
1016
|
if (runMigration === false) {
|
|
618
1017
|
// child may honor RUN_SQL_ON_SYNC
|
|
619
|
-
|
|
1018
|
+
processEnv.RUN_SQL_ON_SYNC = 'false';
|
|
620
1019
|
}
|
|
621
1020
|
|
|
1021
|
+
step = 'start';
|
|
1022
|
+
progress.report(step, 'running');
|
|
622
1023
|
const beforeForReady = await snapshotTablesSafe(app);
|
|
623
|
-
await startProcess(app);
|
|
624
|
-
|
|
1024
|
+
await startProcess(app, { env: processEnv });
|
|
1025
|
+
progress.report(step, 'done');
|
|
1026
|
+
step = 'ready';
|
|
1027
|
+
progress.report(step, 'running');
|
|
1028
|
+
scheduleStatusSync(app.id, {
|
|
1029
|
+
beforeTables: beforeForReady,
|
|
1030
|
+
onSettled: result => (result?.status === 'running' ? progress.report('ready', 'done') : progress.report('ready', 'error', { message: result?.message || result?.status || null }))
|
|
1031
|
+
});
|
|
1032
|
+
await pruneVersionArtifacts(app).catch(err => fastify.log.warn({ err, app: app.name }, 'prune version artifacts failed'));
|
|
625
1033
|
return {
|
|
626
1034
|
name: app.name,
|
|
627
1035
|
status: 'deploying',
|
|
628
1036
|
versionId: String(ver.id),
|
|
1037
|
+
progressId: progress.progressId,
|
|
629
1038
|
port: app.port,
|
|
630
1039
|
pathUrl: `${mountPrefix(app.name)}/`,
|
|
631
1040
|
domain: app.domain
|
|
632
1041
|
};
|
|
633
1042
|
} catch (e) {
|
|
1043
|
+
progress.report(step, 'error', { message: e.message });
|
|
634
1044
|
await app.update({ status: 'error', message: e.message });
|
|
635
1045
|
httpError(400, e.message);
|
|
636
1046
|
}
|
|
@@ -647,15 +1057,28 @@ module.exports = fp(async (fastify, options) => {
|
|
|
647
1057
|
return toPublicApp(app);
|
|
648
1058
|
};
|
|
649
1059
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
1060
|
+
/** 启动 / 重启前先按 schema 校验,不通过直接报错,不改动正在运行的进程与状态 */
|
|
1061
|
+
const prepareStartEnv = async app => {
|
|
1062
|
+
const version = app.currentVersionId ? await models.appVersion.findByPk(app.currentVersionId) : null;
|
|
1063
|
+
if (!version) {
|
|
653
1064
|
httpError(400, 'no version deployed');
|
|
654
1065
|
}
|
|
1066
|
+
const env = await buildProcessEnv(app, version);
|
|
1067
|
+
try {
|
|
1068
|
+
await assertEnvValid(app, version, env);
|
|
1069
|
+
} catch (e) {
|
|
1070
|
+
httpError(400, e.message);
|
|
1071
|
+
}
|
|
1072
|
+
return env;
|
|
1073
|
+
};
|
|
1074
|
+
|
|
1075
|
+
const start = async ({ name }) => {
|
|
1076
|
+
const app = await getByName(name);
|
|
1077
|
+
const env = await prepareStartEnv(app);
|
|
655
1078
|
await app.update({ status: 'deploying', message: null });
|
|
656
1079
|
try {
|
|
657
1080
|
const beforeTables = await snapshotTablesSafe(app);
|
|
658
|
-
await startProcess(app);
|
|
1081
|
+
await startProcess(app, { env });
|
|
659
1082
|
scheduleStatusSync(app.id, { beforeTables });
|
|
660
1083
|
return toPublicApp(await getByName(name));
|
|
661
1084
|
} catch (e) {
|
|
@@ -666,6 +1089,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
666
1089
|
|
|
667
1090
|
const restart = async ({ name }) => {
|
|
668
1091
|
const app = await getByName(name);
|
|
1092
|
+
const env = await prepareStartEnv(app);
|
|
669
1093
|
await app.update({ status: 'deploying', message: null });
|
|
670
1094
|
try {
|
|
671
1095
|
const beforeTables = await snapshotTablesSafe(app);
|
|
@@ -675,7 +1099,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
675
1099
|
} catch (e) {
|
|
676
1100
|
// ignore if not running
|
|
677
1101
|
}
|
|
678
|
-
await startProcess(app);
|
|
1102
|
+
await startProcess(app, { env });
|
|
679
1103
|
scheduleStatusSync(app.id, { beforeTables });
|
|
680
1104
|
return toPublicApp(await getByName(name));
|
|
681
1105
|
} catch (e) {
|
|
@@ -715,6 +1139,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
715
1139
|
const snapshot = app.toJSON();
|
|
716
1140
|
await models.appVersion.destroy({ where: { appName: name } });
|
|
717
1141
|
await fs.remove(app.rootPath).catch(() => {});
|
|
1142
|
+
deployProgress.clear(app.name);
|
|
718
1143
|
await app.destroy();
|
|
719
1144
|
if (typeof options.onAppRemoved === 'function') {
|
|
720
1145
|
try {
|
|
@@ -858,6 +1283,14 @@ module.exports = fp(async (fastify, options) => {
|
|
|
858
1283
|
syncStatus,
|
|
859
1284
|
syncAllStatuses,
|
|
860
1285
|
remove,
|
|
1286
|
+
resolveVersionPackage,
|
|
1287
|
+
listMissingArtifacts,
|
|
1288
|
+
restoreArtifact,
|
|
1289
|
+
restoreMissingArtifacts,
|
|
1290
|
+
pruneVersionArtifacts,
|
|
1291
|
+
pruneAllVersionArtifacts,
|
|
1292
|
+
getMigrationFiles,
|
|
1293
|
+
saveMigrationFiles,
|
|
861
1294
|
logs,
|
|
862
1295
|
logFiles,
|
|
863
1296
|
resolveAppLogFile,
|
|
@@ -865,6 +1298,10 @@ module.exports = fp(async (fastify, options) => {
|
|
|
865
1298
|
removeLogFiles,
|
|
866
1299
|
load,
|
|
867
1300
|
getLoadStore,
|
|
1301
|
+
getDeployProgress: () => deployProgress,
|
|
1302
|
+
waitDeployProgress: ({ name, progressId, timeoutMs }) => deployProgress.wait(name, progressId, { timeoutMs }),
|
|
1303
|
+
getEnvDeclaration,
|
|
1304
|
+
listInjectedEnvKeys,
|
|
868
1305
|
toPublicApp,
|
|
869
1306
|
mountPrefix,
|
|
870
1307
|
findByHost,
|