@kne/fastify-app-manager 0.1.1 → 0.1.3

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.
@@ -2,7 +2,7 @@ const fp = require('fastify-plugin');
2
2
  const path = require('node:path');
3
3
  const fs = require('fs-extra');
4
4
  const portPool = require('../utils/portPool');
5
- const { mergeEnv, maskEnvForResponse, applyEnvPatch, mergePm2Config } = require('../utils/env');
5
+ const { mergeEnv, maskEnvForResponse, applyEnvPatch, mergePm2Config, normalizeSecretKeys, collectSecretKeys } = require('../utils/env');
6
6
  const { prepareVersionArtifact } = require('../utils/version');
7
7
  const { injectEntryHtml } = require('../utils/entryInject');
8
8
  const { ensureLogFiles, readLogTail, readLastLines, appendLog } = require('../utils/logFiles');
@@ -29,13 +29,18 @@ module.exports = fp(async (fastify, options) => {
29
29
  const appRoot = name => path.join(options.appsRoot, name);
30
30
  const mountPrefix = name => `${options.pathPrefix.replace(/\/$/, '')}/${name}`;
31
31
 
32
+ const resolveSecretEnvKeys = appLike => normalizeSecretKeys(appLike?.options?.secretEnvKeys);
33
+
32
34
  const toPublicApp = app => {
33
35
  if (!app) {
34
36
  return null;
35
37
  }
36
38
  const json = typeof app.toJSON === 'function' ? app.toJSON() : { ...app };
37
- json.env = maskEnvForResponse(json.env || {}, options);
39
+ const explicitSecrets = resolveSecretEnvKeys(json);
40
+ const envOpts = Object.assign({}, options, { secretEnvKeys: explicitSecrets });
41
+ json.env = maskEnvForResponse(json.env || {}, envOpts);
38
42
  json.passthroughEnvKeys = options.passthroughEnvKeys || [];
43
+ json.secretEnvKeys = collectSecretKeys(json.env || {}, envOpts);
39
44
  json.pathUrl = `${mountPrefix(json.name)}/`;
40
45
  return json;
41
46
  };
@@ -79,6 +84,11 @@ module.exports = fp(async (fastify, options) => {
79
84
  portMax: options.portMax
80
85
  });
81
86
 
87
+ const normalizedOptions = Object.assign({}, appOptions || {});
88
+ if (normalizedOptions.secretEnvKeys !== undefined) {
89
+ normalizedOptions.secretEnvKeys = normalizeSecretKeys(normalizedOptions.secretEnvKeys);
90
+ }
91
+
82
92
  const app = await models.app.create({
83
93
  name,
84
94
  label,
@@ -87,7 +97,7 @@ module.exports = fp(async (fastify, options) => {
87
97
  description: description || null,
88
98
  env: env || {},
89
99
  pm2Config: pm2Config || {},
90
- options: appOptions || {},
100
+ options: normalizedOptions,
91
101
  port,
92
102
  status: 'idle',
93
103
  rootPath,
@@ -114,16 +124,29 @@ module.exports = fp(async (fastify, options) => {
114
124
  }
115
125
  }
116
126
  if (patch.env) {
117
- patch.env = applyEnvPatch(app.env || {}, patch.env, options);
127
+ const secretEnvKeys = resolveSecretEnvKeys(app);
128
+ patch.env = applyEnvPatch(app.env || {}, patch.env, Object.assign({}, options, { secretEnvKeys }));
129
+ }
130
+ if (patch.options && patch.options.secretEnvKeys !== undefined) {
131
+ patch.options = Object.assign({}, patch.options, {
132
+ secretEnvKeys: normalizeSecretKeys(patch.options.secretEnvKeys)
133
+ });
118
134
  }
119
135
  await app.update(patch);
120
136
  return toPublicApp(app);
121
137
  };
122
138
 
123
- const saveEnv = async ({ name, env }) => {
139
+ const saveEnv = async ({ name, env, secretEnvKeys: nextSecretKeys } = {}) => {
124
140
  const app = await getByName(name);
125
- const next = applyEnvPatch(app.env || {}, env || {}, options);
126
- await app.update({ env: next });
141
+ const currentSecrets = resolveSecretEnvKeys(app);
142
+ const secretEnvKeys = nextSecretKeys !== undefined ? normalizeSecretKeys(nextSecretKeys) : currentSecrets;
143
+ const nextEnv = applyEnvPatch(app.env || {}, env || {}, Object.assign({}, options, { secretEnvKeys }));
144
+ const cleanedSecrets = secretEnvKeys.filter(key => Object.prototype.hasOwnProperty.call(nextEnv, key));
145
+ await app.update({
146
+ env: nextEnv,
147
+ options: Object.assign({}, app.options || {}, { secretEnvKeys: cleanedSecrets })
148
+ });
149
+ await app.reload();
127
150
  return toPublicApp(app);
128
151
  };
129
152
 
@@ -235,6 +258,139 @@ module.exports = fp(async (fastify, options) => {
235
258
  return false;
236
259
  };
237
260
 
261
+ const getPm2ProcessStatus = async pm2Name => {
262
+ try {
263
+ const list = await pm2Util.describe(pm2Name);
264
+ const proc = list?.[0];
265
+ return proc?.pm2_env?.status || null;
266
+ } catch (e) {
267
+ return null;
268
+ }
269
+ };
270
+
271
+ /** 同一 app 并发同步合并为一次,避免 restart/reconcile 竞态 */
272
+ const statusSyncInflight = new Map();
273
+
274
+ /**
275
+ * 根据 PM2 真实进程状态 + HTTP 健康检查,异步写回 DB status。
276
+ * @param {string|number} appId
277
+ * @param {{ recoverIfMissing?: boolean }} [opts] recoverIfMissing:DB 期望在跑但进程不在时尝试 startProcess
278
+ */
279
+ const syncRealStatus = async (appId, { recoverIfMissing = false, beforeTables } = {}) => {
280
+ const key = String(appId);
281
+ if (statusSyncInflight.has(key)) {
282
+ return statusSyncInflight.get(key);
283
+ }
284
+ const run = (async () => {
285
+ const app = await models.app.findByPk(appId);
286
+ if (!app) {
287
+ return null;
288
+ }
289
+ try {
290
+ let pm2Status = await getPm2ProcessStatus(app.pm2Name);
291
+
292
+ if ((!pm2Status || pm2Status === 'stopped' || pm2Status === 'stopping') && recoverIfMissing && app.currentVersionId) {
293
+ try {
294
+ await startProcess(app);
295
+ pm2Status = (await getPm2ProcessStatus(app.pm2Name)) || 'online';
296
+ } catch (e) {
297
+ await app.update({ status: 'error', message: e.message });
298
+ return toPublicApp(app);
299
+ }
300
+ }
301
+
302
+ if (!pm2Status || pm2Status === 'stopped' || pm2Status === 'stopping') {
303
+ if (app.status !== 'stopped' && app.status !== 'idle') {
304
+ await app.update({ status: 'stopped', message: null });
305
+ }
306
+ return toPublicApp(await models.app.findByPk(appId));
307
+ }
308
+
309
+ if (pm2Status === 'errored') {
310
+ await app.update({ status: 'error', message: 'pm2 process errored' });
311
+ return toPublicApp(await models.app.findByPk(appId));
312
+ }
313
+
314
+ // online / launching / waiting 等:探测 HTTP 就绪
315
+ const ok = await healthCheck(app.port, {
316
+ timeoutMs: options.healthCheckTimeoutMs,
317
+ intervalMs: options.healthCheckIntervalMs,
318
+ healthPath: options.healthCheckPath
319
+ });
320
+ if (ok) {
321
+ await app.update({ status: 'running', message: null });
322
+ try {
323
+ const fresh = await models.app.findByPk(appId);
324
+ const dbops = fastify[options.name].services.dbops;
325
+ await dbops.claimOwnedTablesByPrefix(fresh);
326
+ if (Array.isArray(beforeTables)) {
327
+ const afterTables = await dbops.withSequelize(fresh, sequelize => dbops.listAllTables(sequelize));
328
+ await fresh.reload();
329
+ await dbops.mergeOwnedTablesFromDiff(fresh, beforeTables, afterTables);
330
+ }
331
+ } catch (e) {
332
+ fastify.log.warn({ err: e, appId }, 'claim owned tables after ready failed');
333
+ }
334
+ } else {
335
+ const again = await getPm2ProcessStatus(app.pm2Name);
336
+ if (again === 'errored') {
337
+ await app.update({ status: 'error', message: 'pm2 process errored' });
338
+ } else if (again === 'online' || again === 'launching') {
339
+ await app.update({ status: 'error', message: 'health check timeout' });
340
+ } else {
341
+ await app.update({ status: 'stopped', message: 'process not online after health check' });
342
+ }
343
+ }
344
+ return toPublicApp(await models.app.findByPk(appId));
345
+ } catch (e) {
346
+ await app.update({ status: 'error', message: e.message });
347
+ return toPublicApp(await models.app.findByPk(appId));
348
+ }
349
+ })().finally(() => {
350
+ statusSyncInflight.delete(key);
351
+ });
352
+ statusSyncInflight.set(key, run);
353
+ return run;
354
+ };
355
+
356
+ const snapshotTablesSafe = async app => {
357
+ try {
358
+ const dbops = fastify[options.name].services.dbops;
359
+ return await dbops.withSequelize(app, sequelize => dbops.listAllTables(sequelize));
360
+ } catch (e) {
361
+ return [];
362
+ }
363
+ };
364
+
365
+ const finishDeployAsync = async appId => syncRealStatus(appId, { recoverIfMissing: false });
366
+
367
+ const scheduleStatusSync = (appId, opts) => {
368
+ setImmediate(() => {
369
+ syncRealStatus(appId, opts).catch(err => {
370
+ fastify.log.warn({ err, appId }, 'syncRealStatus failed');
371
+ });
372
+ });
373
+ };
374
+
375
+ const syncStatus = async ({ name, recoverIfMissing = false } = {}) => {
376
+ const app = await getByName(name);
377
+ return syncRealStatus(app.id, { recoverIfMissing });
378
+ };
379
+
380
+ const syncAllStatuses = async ({ recoverIfMissing = true } = {}) => {
381
+ const apps = await models.app.findAll({
382
+ where: {
383
+ status: ['running', 'deploying', 'stopped', 'error']
384
+ }
385
+ });
386
+ const results = [];
387
+ for (const app of apps) {
388
+ const shouldRecover = recoverIfMissing && (app.status === 'running' || app.status === 'deploying');
389
+ results.push(await syncRealStatus(app.id, { recoverIfMissing: shouldRecover }));
390
+ }
391
+ return results.filter(Boolean);
392
+ };
393
+
238
394
  const startProcess = async app => {
239
395
  const version = await models.appVersion.findByPk(app.currentVersionId);
240
396
  if (!version) {
@@ -245,9 +401,19 @@ module.exports = fp(async (fastify, options) => {
245
401
  const env = mergeEnv({
246
402
  passthroughEnvKeys: options.passthroughEnvKeys,
247
403
  appEnv: app.env,
248
- port: app.port
404
+ port: app.port,
405
+ defaultAppDb: options.defaultAppDb,
406
+ appName: app.name
249
407
  });
250
408
 
409
+ // Persist injected shared-DB table prefix so claim/UI see the same value
410
+ if (env.DB_TABLE_PREFIX && app.env?.DB_TABLE_PREFIX !== env.DB_TABLE_PREFIX) {
411
+ await app.update({
412
+ env: Object.assign({}, app.env || {}, { DB_TABLE_PREFIX: env.DB_TABLE_PREFIX })
413
+ });
414
+ await app.reload();
415
+ }
416
+
251
417
  const pathBase = mountPrefix(app.name);
252
418
  // Domain-first: inject `/` so custom Host SPA works; path mode then relies on gateway rewrite fallback.
253
419
  // Path-only: inject `/app/{name}` so runtime* matches strip-prefix gateway.
@@ -270,27 +436,6 @@ module.exports = fp(async (fastify, options) => {
270
436
  });
271
437
  };
272
438
 
273
- const finishDeployAsync = async appId => {
274
- const app = await models.app.findByPk(appId);
275
- if (!app) {
276
- return;
277
- }
278
- try {
279
- const ok = await healthCheck(app.port, {
280
- timeoutMs: options.healthCheckTimeoutMs,
281
- intervalMs: options.healthCheckIntervalMs,
282
- healthPath: options.healthCheckPath
283
- });
284
- if (ok) {
285
- await app.update({ status: 'running', message: null });
286
- } else {
287
- await app.update({ status: 'error', message: 'health check timeout' });
288
- }
289
- } catch (e) {
290
- await app.update({ status: 'error', message: e.message });
291
- }
292
- };
293
-
294
439
  const deploy = async ({ name, versionId, version, runMigration = true }) => {
295
440
  const app = await getByName(name);
296
441
  let ver = null;
@@ -321,13 +466,30 @@ module.exports = fp(async (fastify, options) => {
321
466
  const env = mergeEnv({
322
467
  passthroughEnvKeys: options.passthroughEnvKeys,
323
468
  appEnv: app.env,
324
- port: app.port
469
+ port: app.port,
470
+ defaultAppDb: options.defaultAppDb,
471
+ appName: app.name
325
472
  });
473
+ const dbops = fastify[options.name].services.dbops;
474
+ let beforeTables = [];
475
+ try {
476
+ beforeTables = await dbops.withSequelize(app, async sequelize => dbops.listAllTables(sequelize));
477
+ } catch (e) {
478
+ beforeTables = [];
479
+ }
326
480
  await runSqlMigrations({
327
481
  serverDir: path.join(ver.artifactPath, 'server'),
328
482
  sqlPath: ver.migrationPath || options.sqlPath,
329
- env
483
+ env,
484
+ Sequelize: fastify.sequelize.Sequelize
330
485
  });
486
+ try {
487
+ const afterTables = await dbops.withSequelize(app, async sequelize => dbops.listAllTables(sequelize));
488
+ await app.reload();
489
+ await dbops.mergeOwnedTablesFromDiff(app, beforeTables, afterTables);
490
+ } catch (e) {
491
+ // ignore ownership merge failures
492
+ }
331
493
  }
332
494
 
333
495
  if (runMigration === false) {
@@ -335,8 +497,9 @@ module.exports = fp(async (fastify, options) => {
335
497
  app.env = Object.assign({}, app.env, { RUN_SQL_ON_SYNC: 'false' });
336
498
  }
337
499
 
500
+ const beforeForReady = await snapshotTablesSafe(app);
338
501
  await startProcess(app);
339
- setImmediate(() => finishDeployAsync(app.id));
502
+ scheduleStatusSync(app.id, { beforeTables: beforeForReady });
340
503
  return {
341
504
  name: app.name,
342
505
  status: 'deploying',
@@ -369,8 +532,9 @@ module.exports = fp(async (fastify, options) => {
369
532
  }
370
533
  await app.update({ status: 'deploying', message: null });
371
534
  try {
535
+ const beforeTables = await snapshotTablesSafe(app);
372
536
  await startProcess(app);
373
- setImmediate(() => finishDeployAsync(app.id));
537
+ scheduleStatusSync(app.id, { beforeTables });
374
538
  return toPublicApp(await getByName(name));
375
539
  } catch (e) {
376
540
  await app.update({ status: 'error', message: e.message });
@@ -382,38 +546,64 @@ module.exports = fp(async (fastify, options) => {
382
546
  const app = await getByName(name);
383
547
  await app.update({ status: 'deploying', message: null });
384
548
  try {
385
- await pm2Util.restartApp(app.pm2Name);
386
- setImmediate(() => finishDeployAsync(app.id));
387
- return toPublicApp(await getByName(name));
388
- } catch (e) {
549
+ const beforeTables = await snapshotTablesSafe(app);
550
+ // Always startProcess so env (incl. DB_TABLE_PREFIX) is refreshed; pm2.restart keeps stale env
389
551
  try {
390
- await startProcess(app);
391
- setImmediate(() => finishDeployAsync(app.id));
392
- return toPublicApp(await getByName(name));
393
- } catch (e2) {
394
- await app.update({ status: 'error', message: e2.message });
395
- httpError(400, e2.message);
552
+ await pm2Util.stopApp(app.pm2Name);
553
+ } catch (e) {
554
+ // ignore if not running
396
555
  }
556
+ await startProcess(app);
557
+ scheduleStatusSync(app.id, { beforeTables });
558
+ return toPublicApp(await getByName(name));
559
+ } catch (e) {
560
+ await app.update({ status: 'error', message: e.message });
561
+ httpError(400, e.message);
397
562
  }
398
563
  };
399
564
 
400
- const remove = async ({ name }) => {
565
+ const remove = async ({ name, exportBeforeRemove = true, cleanupData = true, allowRemoveIfCleanupIncomplete = false, force = false, removeSqliteFile = false } = {}) => {
401
566
  const app = await getByName(name);
402
567
  try {
403
568
  await pm2Util.deleteProcess(app.pm2Name);
404
569
  } catch (e) {
405
570
  // ignore
406
571
  }
572
+
573
+ const dbops = fastify[options.name].services.dbops;
574
+ let exportResult = null;
575
+ let cleanupResult = null;
576
+
577
+ if (exportBeforeRemove && !force) {
578
+ exportResult = await dbops.exportData({ name });
579
+ }
580
+
581
+ if (cleanupData) {
582
+ cleanupResult = await dbops.cleanup({ name, removeSqliteFile });
583
+ if (cleanupResult.manualRequired && !allowRemoveIfCleanupIncomplete) {
584
+ return {
585
+ removed: false,
586
+ reason: 'cleanup incomplete; execute returned sql or pass allowRemoveIfCleanupIncomplete=true',
587
+ export: exportResult,
588
+ cleanup: cleanupResult
589
+ };
590
+ }
591
+ }
592
+
407
593
  await models.appVersion.destroy({ where: { appName: name } });
408
594
  await fs.remove(app.rootPath).catch(() => {});
409
595
  await app.destroy();
410
- return {};
596
+ return {
597
+ removed: true,
598
+ export: exportResult,
599
+ cleanup: cleanupResult
600
+ };
411
601
  };
412
602
 
413
- const logs = async ({ name, stream = 'out', perPage = 100, currentPage = 1 }) => {
603
+ const logs = async ({ name, stream = 'out', perPage = 100, currentPage = 1, beforeLine } = {}) => {
414
604
  const app = await getByName(name);
415
605
  const file = path.join(app.rootPath, 'logs', stream === 'err' ? 'err.log' : 'out.log');
416
- return readLogTail(file, { perPage, currentPage });
606
+ return readLogTail(file, { perPage, currentPage, beforeLine });
417
607
  };
418
608
 
419
609
  const findByHost = async host => {
@@ -441,6 +631,8 @@ module.exports = fp(async (fastify, options) => {
441
631
  start,
442
632
  stop,
443
633
  restart,
634
+ syncStatus,
635
+ syncAllStatuses,
444
636
  remove,
445
637
  logs,
446
638
  toPublicApp,
@@ -462,7 +654,14 @@ module.exports = fp(async (fastify, options) => {
462
654
  await appendLog(file, content);
463
655
  const hub = getLogHub();
464
656
  if (hub) {
465
- hub.emit(`log:${appName}`, { appName, stream, content, loggedAt: new Date().toISOString() });
657
+ const { totalCount } = await readLogTail(file, { perPage: 1, currentPage: 1 });
658
+ hub.emit(`log:${appName}`, {
659
+ appName,
660
+ stream,
661
+ content,
662
+ line: totalCount,
663
+ loggedAt: new Date().toISOString()
664
+ });
466
665
  }
467
666
  }
468
667
  }
@@ -52,7 +52,12 @@ module.exports = fp(async (fastify, options) => {
52
52
  fastify.log.warn({ err: e }, 'pm2 launchBus failed');
53
53
  }
54
54
 
55
- await reconcile();
55
+ // 父应用重启后不阻塞 ready:后台按 PM2 真实状态异步对齐 DB
56
+ setImmediate(() => {
57
+ reconcile().catch(err => {
58
+ fastify.log.warn({ err }, 'async reconcile/sync statuses failed');
59
+ });
60
+ });
56
61
  };
57
62
 
58
63
  const fsEnsureAppsRoot = async () => {
@@ -61,26 +66,11 @@ module.exports = fp(async (fastify, options) => {
61
66
  };
62
67
 
63
68
  const reconcile = async () => {
64
- const { models, services } = fastify[options.name];
65
- const apps = await models.app.findAll({
66
- where: { status: ['running', 'deploying'] }
67
- });
68
- let list = [];
69
- try {
70
- list = await pm2Util.list();
71
- } catch (e) {
72
- return;
73
- }
74
- const runningNames = new Set(list.filter(p => p.pm2_env?.status === 'online').map(p => p.name));
75
- for (const app of apps) {
76
- if (!runningNames.has(app.pm2Name)) {
77
- try {
78
- await services.app.start({ name: app.name });
79
- } catch (e) {
80
- await app.update({ status: 'error', message: `reconcile failed: ${e.message}` });
81
- }
82
- }
69
+ const { services } = fastify[options.name];
70
+ if (typeof services.app.syncAllStatuses === 'function') {
71
+ return services.app.syncAllStatuses({ recoverIfMissing: true });
83
72
  }
73
+ return [];
84
74
  };
85
75
 
86
76
  const onClose = async () => {