@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.
@@ -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 { mergeEnv, maskEnvForResponse, applyEnvPatch, mergePm2Config, normalizeSecretKeys, collectSecretKeys, isSystemEnvKey, omitSystemEnv } = require('../utils/env');
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 { listSqlFiles } = require('../utils/validatePackage');
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
- const nextEnv = applyEnvPatch(app.env || {}, env || {}, Object.assign({}, options, { secretEnvKeys }));
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 uploadVersion = async ({ name, version, label, zipBuffer }) => {
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
- return await models.appVersion.sequelize.transaction(async transaction => {
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: prepared.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
- await fs.move(stagingPath, artifactPath);
382
+ if (keepArtifact) {
383
+ await fs.move(stagingPath, artifactPath);
384
+ }
304
385
  await versionRow.update({ artifactPath }, { transaction });
305
- return versionRow.toJSON();
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 json = row.toJSON();
330
- if (json.hasMigration) {
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).catch(err => {
465
- fastify.log.warn({ err, appId }, 'syncRealStatus failed');
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
- const startProcess = async app => {
490
- const version = await models.appVersion.findByPk(app.currentVersionId);
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 deploy = async ({ name, versionId, version, runMigration = true, migrations }) => {
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
- const currentLink = path.join(app.rootPath, 'current');
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
- app.env = Object.assign({}, app.env, { RUN_SQL_ON_SYNC: 'false' });
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
- scheduleStatusSync(app.id, { beforeTables: beforeForReady });
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
- const start = async ({ name }) => {
651
- const app = await getByName(name);
652
- if (!app.currentVersionId) {
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,