@kne/fastify-app-manager 0.1.5 → 0.1.7
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 +37 -3
- package/libs/controllers/app.js +13 -1
- package/libs/controllers/gateway.js +45 -6
- package/libs/controllers/migration.js +97 -0
- package/libs/services/app.js +14 -7
- package/libs/services/migration.js +166 -0
- package/libs/utils/migrate.js +58 -16
- package/libs/utils/unavailablePage.js +77 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,6 +49,7 @@ Client / Admin UI
|
|
|
49
49
|
| Management API | 创建应用、上传版本、部署、启停、日志;均走 `createAuthenticate` |
|
|
50
50
|
| Domain gateway | Host 精确匹配且状态为 `running` 时透明反代,不改写路径 |
|
|
51
51
|
| Path gateway | `/app/{name}` 剥前缀;改写绝对路径 Location、Cookie Path、文本类 body |
|
|
52
|
+
| 未运行兜底 | 应用存在但未 `running` 时,网关返回 503 状态页(已停止 / 启动中 / 异常 / 未部署),接口请求返回 JSON,不再落到宿主 404;`/app/{name}` 下应用不存在时返回 404「应用不存在」页 |
|
|
52
53
|
| PM2 | 每应用一进程,名 `app-manager__{name}`;stdout/stderr 落 `appsRoot/{name}/logs` |
|
|
53
54
|
| Bootstrap | `onReady` 连接 PM2、挂 log bus;**异步**对托管应用做 `syncAllStatuses`(不阻塞父应用 ready) |
|
|
54
55
|
|
|
@@ -350,6 +351,15 @@ await fastify.listen({ port: 3000 });
|
|
|
350
351
|
| versionId | string | 否 | - | 版本主键;与 `version` 二选一 |
|
|
351
352
|
| version | string | 否 | - | 版本号 |
|
|
352
353
|
| runMigration | boolean | 否 | `true` | false 时向 app.env 写入 `RUN_SQL_ON_SYNC=false`;预跑 SQL 仍受 `migrateBeforeStart` 约束 |
|
|
354
|
+
| migrations | array | 否 | - | `[{ name, action }]` 逐个指定**待执行**脚本的处理方式;未列出的按 `execute`。仅在 `runMigration` 且 `migrateBeforeStart` 时生效 |
|
|
355
|
+
|
|
356
|
+
`migrations[].action`:
|
|
357
|
+
|
|
358
|
+
| 值 | 行为 |
|
|
359
|
+
|----|------|
|
|
360
|
+
| `execute` | 执行脚本并写入 `_fs_sql_migrations` |
|
|
361
|
+
| `skip` | 不执行,只写入执行记录(之后部署和子应用 sync 都不会再执行) |
|
|
362
|
+
| `hold` | 本次不执行也不记账,保持待执行;子应用若启用 `@kne/fastify-sequelize` 的 `runSqlOnSync`,启动时仍可能执行 |
|
|
353
363
|
|
|
354
364
|
返回示例:
|
|
355
365
|
|
|
@@ -364,6 +374,18 @@ await fastify.listen({ port: 3000 });
|
|
|
364
374
|
}
|
|
365
375
|
```
|
|
366
376
|
|
|
377
|
+
##### 版本迁移脚本管理
|
|
378
|
+
|
|
379
|
+
管理某版本 `server/{migrationPath}` 下的 `.sql` 文件,执行状态读写应用库的 `_fs_sql_migrations`(与 `@kne/fastify-sequelize` 共用,按文件名记账;共享库中不同应用的同名脚本会互相影响)。文件名须匹配 `[A-Za-z0-9_-.]+\.sql`。
|
|
380
|
+
|
|
381
|
+
| 方法 | 路径 | 参数 | 说明 |
|
|
382
|
+
|------|------|------|------|
|
|
383
|
+
| GET | `{prefix}/app/version/migration/list` | `name`, `versionId` | 脚本列表:`name`、`size`、`updatedAt`、`executed`、`executedAt`;另返回 `migrateBeforeStart`,库连接失败时 `dbError` 有值且 `executed` 为 null |
|
|
384
|
+
| GET | `{prefix}/app/version/migration/content` | `name`, `versionId`, `file` | 返回 `{ name, content }` |
|
|
385
|
+
| POST | `{prefix}/app/version/migration/save` | `name`, `versionId`, `file`, `content` | 新增或覆盖脚本文件,并同步版本 `hasMigration` |
|
|
386
|
+
| POST | `{prefix}/app/version/migration/remove` | `name`, `versionId`, `file` | 删除脚本文件(不改执行记录),并同步 `hasMigration` |
|
|
387
|
+
| POST | `{prefix}/app/version/migration/action` | `name`, `versionId`, `file`, `action` | `execute` 立即执行并记账(执行后按表差异合并归属表);`mark` 只记账;`unmark` 删除执行记录 |
|
|
388
|
+
|
|
367
389
|
##### GET `{prefix}/app/list`
|
|
368
390
|
|
|
369
391
|
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
|
@@ -449,14 +471,14 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
|
|
|
449
471
|
| `detail({ name })` | 详情(脱敏) |
|
|
450
472
|
| `uploadVersion({ name, version, label, zipBuffer })` | 上传版本 |
|
|
451
473
|
| `listVersions({ name, perPage, currentPage })` | 版本列表 |
|
|
452
|
-
| `deploy({ name, versionId, version, runMigration })` | 部署;立即 `deploying`,后台 `syncRealStatus` |
|
|
474
|
+
| `deploy({ name, versionId, version, runMigration, migrations? })` | 部署;立即 `deploying`,后台 `syncRealStatus` |
|
|
453
475
|
| `start` / `stop` / `restart` | 生命周期;`start`/`restart` 立即 `deploying`,后台按 PM2+health 写回真实状态 |
|
|
454
476
|
| `syncStatus({ name, recoverIfMissing? })` | 同步单个应用真实状态并返回公开对象 |
|
|
455
477
|
| `syncAllStatuses({ recoverIfMissing? })` | 批量同步(父应用启动 reconcile 使用) |
|
|
456
478
|
| `remove({ name, exportBeforeRemove?, cleanupData?, ... })` | 删除;默认可导出+清理所属表 |
|
|
457
479
|
| `logs({ name, stream, perPage, currentPage })` | 读日志文件 |
|
|
458
|
-
| `findByHost(host)` | 网关:按 domain
|
|
459
|
-
| `findByPathName(name)` | 网关:按 name
|
|
480
|
+
| `findByHost(host, { running? })` | 网关:按 domain 查找;默认只查 `running`,`running: false` 时不限状态 |
|
|
481
|
+
| `findByPathName(name, { running? })` | 网关:按 name 查找;默认只查 `running`,`running: false` 时不限状态 |
|
|
460
482
|
| `appendAppLog(appName, stream, content)` | 追加日志并 emit hub 事件 |
|
|
461
483
|
| `readLastLines(name, stream, n)` | SSE 回放用 |
|
|
462
484
|
| `getLogHub()` | 获取 EventEmitter |
|
|
@@ -471,6 +493,16 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
|
|
|
471
493
|
| `runQuery({ name, sql, replacements? })` | 只读 SQL |
|
|
472
494
|
| `exportData` / `cleanup` | 导出与清理 |
|
|
473
495
|
|
|
496
|
+
##### services.migration
|
|
497
|
+
|
|
498
|
+
| 方法签名 | 说明 |
|
|
499
|
+
|----------|------|
|
|
500
|
+
| `list({ name, versionId })` | 版本脚本及执行状态 |
|
|
501
|
+
| `content({ name, versionId, file })` | 读脚本内容 |
|
|
502
|
+
| `save({ name, versionId, file, content })` / `remove({ name, versionId, file })` | 新增覆盖 / 删除脚本文件 |
|
|
503
|
+
| `execute` / `mark` / `unmark`(`{ name, versionId, file }`) | 执行并记账 / 只记账 / 删除记录 |
|
|
504
|
+
| `action({ name, versionId, file, action })` | 按 `action` 分发到上面三个方法 |
|
|
505
|
+
|
|
474
506
|
##### services.bootstrap
|
|
475
507
|
|
|
476
508
|
| 方法签名 | 说明 |
|
|
@@ -526,6 +558,8 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
|
|
|
526
558
|
| 机制 | 说明 |
|
|
527
559
|
|------|------|
|
|
528
560
|
| 路由优先级 | 管理 API(`prefix`)不进网关;先 domain 匹配,再 path 前缀匹配 |
|
|
561
|
+
| 应用未运行 | 命中已存在但非 `running` 的应用时返回 503(`Cache-Control: no-store`、`Retry-After`):浏览器 `GET`/`HEAD` 且 `Accept` 含 `text/html` 返回状态说明页(`deploying` 每 5 秒自动刷新),其余请求返回 JSON `{ statusCode, error, message, appName, appStatus }` |
|
|
562
|
+
| 应用不存在 | `/app/{name}` 下找不到应用时返回 404「应用不存在」页(带返回首页)或 JSON(`appStatus: 'missing'`);`pathPrefix` 之外以及未绑定的域名仍交给宿主路由 |
|
|
529
563
|
| Location | 相对路径前缀补上 `/app/{name}` |
|
|
530
564
|
| Set-Cookie | `Path=/` 改为 `Path=/app/{name}` |
|
|
531
565
|
| Body | 对文本类 Content-Type,将 `"/static/`、`"/api/`、`"/account/` 等前缀改写;有 `content-encoding` 时跳过 |
|
package/libs/controllers/app.js
CHANGED
|
@@ -139,7 +139,19 @@ module.exports = fp(async (fastify, options) => {
|
|
|
139
139
|
name: { type: 'string' },
|
|
140
140
|
versionId: { type: 'string' },
|
|
141
141
|
version: { type: 'string' },
|
|
142
|
-
runMigration: { type: 'boolean', default: true }
|
|
142
|
+
runMigration: { type: 'boolean', default: true },
|
|
143
|
+
migrations: {
|
|
144
|
+
type: 'array',
|
|
145
|
+
description: '逐个指定待执行脚本的处理方式;未列出的待执行脚本按 execute 处理',
|
|
146
|
+
items: {
|
|
147
|
+
type: 'object',
|
|
148
|
+
required: ['name', 'action'],
|
|
149
|
+
properties: {
|
|
150
|
+
name: { type: 'string' },
|
|
151
|
+
action: { type: 'string', enum: ['execute', 'skip', 'hold'] }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
143
155
|
}
|
|
144
156
|
}
|
|
145
157
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const fp = require('fastify-plugin');
|
|
2
|
+
const { Readable } = require('node:stream');
|
|
2
3
|
const { rewriteLocation, rewriteSetCookiePath, shouldRewriteBody, rewriteBody } = require('../utils/pathRewrite');
|
|
4
|
+
const { wantsHtml, renderUnavailablePage, getStatusText } = require('../utils/unavailablePage');
|
|
3
5
|
|
|
4
6
|
module.exports = fp(async (fastify, options) => {
|
|
5
7
|
const ns = options.name;
|
|
@@ -20,6 +22,12 @@ module.exports = fp(async (fastify, options) => {
|
|
|
20
22
|
const dest = `http://127.0.0.1:${app.port}${targetPath}`;
|
|
21
23
|
const mountPrefix = `${pathPrefix}/${app.name}`;
|
|
22
24
|
|
|
25
|
+
// The gateway runs in onRequest, before body parsing; reply-from only forwards request.body,
|
|
26
|
+
// so hand it the raw stream or POST/PUT bodies reach the app empty.
|
|
27
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
28
|
+
request.body = request.raw;
|
|
29
|
+
}
|
|
30
|
+
|
|
23
31
|
return reply.from(dest, {
|
|
24
32
|
rewriteRequestHeaders: (req, headers) => {
|
|
25
33
|
const next = { ...headers };
|
|
@@ -51,13 +59,43 @@ module.exports = fp(async (fastify, options) => {
|
|
|
51
59
|
}
|
|
52
60
|
const buf = Buffer.concat(chunks.map(c => (Buffer.isBuffer(c) ? c : Buffer.from(c))));
|
|
53
61
|
const text = rewriteBody(buf.toString('utf8'), mountPrefix);
|
|
54
|
-
|
|
62
|
+
// A stream payload keeps host onSend hooks that wrap string bodies (e.g. response envelopes) from altering app responses.
|
|
63
|
+
return reply.send(Readable.from([Buffer.from(text)], { objectMode: false }));
|
|
55
64
|
}
|
|
56
65
|
return reply.send(res.stream);
|
|
57
66
|
}
|
|
58
67
|
});
|
|
59
68
|
};
|
|
60
69
|
|
|
70
|
+
// Not running (503) or unknown under the path prefix (404): answer here instead of falling through to the host's 404.
|
|
71
|
+
const replyUnavailable = (request, reply, app) => {
|
|
72
|
+
const missing = app.status === 'missing';
|
|
73
|
+
const statusCode = missing ? 404 : 503;
|
|
74
|
+
reply.code(statusCode).header('cache-control', 'no-store');
|
|
75
|
+
if (!missing) {
|
|
76
|
+
reply.header('retry-after', app.status === 'deploying' ? '5' : '60');
|
|
77
|
+
}
|
|
78
|
+
if (wantsHtml(request)) {
|
|
79
|
+
return reply.type('text/html; charset=utf-8').send(Readable.from([Buffer.from(renderUnavailablePage(app))], { objectMode: false }));
|
|
80
|
+
}
|
|
81
|
+
const payload = {
|
|
82
|
+
statusCode,
|
|
83
|
+
error: missing ? 'Not Found' : 'Service Unavailable',
|
|
84
|
+
message: getStatusText(app.status).title,
|
|
85
|
+
appName: app.name,
|
|
86
|
+
appStatus: app.status
|
|
87
|
+
};
|
|
88
|
+
return reply.type('application/json; charset=utf-8').send(Readable.from([Buffer.from(JSON.stringify(payload))], { objectMode: false }));
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const decodeName = raw => {
|
|
92
|
+
try {
|
|
93
|
+
return decodeURIComponent(raw);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
return raw;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
61
99
|
// Domain + path gateway: run early, but skip management API and let other routes try first for non-matches.
|
|
62
100
|
fastify.addHook('onRequest', async (request, reply) => {
|
|
63
101
|
const url = request.raw.url || '';
|
|
@@ -71,18 +109,19 @@ module.exports = fp(async (fastify, options) => {
|
|
|
71
109
|
}
|
|
72
110
|
|
|
73
111
|
const host = request.headers.host;
|
|
74
|
-
const byHost = await services.app.findByHost(host);
|
|
112
|
+
const byHost = await services.app.findByHost(host, { running: false });
|
|
75
113
|
if (byHost) {
|
|
76
|
-
return proxyToApp(request, reply, byHost, { stripPrefix: false });
|
|
114
|
+
return byHost.status === 'running' ? proxyToApp(request, reply, byHost, { stripPrefix: false }) : replyUnavailable(request, reply, byHost);
|
|
77
115
|
}
|
|
78
116
|
|
|
79
117
|
const match = url.match(new RegExp(`^${pathPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/([^/?#]+)`));
|
|
80
118
|
if (match) {
|
|
81
|
-
const appName = match[1];
|
|
82
|
-
const app = await services.app.findByPathName(appName);
|
|
119
|
+
const appName = decodeName(match[1]);
|
|
120
|
+
const app = await services.app.findByPathName(appName, { running: false });
|
|
83
121
|
if (app) {
|
|
84
|
-
return proxyToApp(request, reply, app, { stripPrefix: true });
|
|
122
|
+
return app.status === 'running' ? proxyToApp(request, reply, app, { stripPrefix: true }) : replyUnavailable(request, reply, app);
|
|
85
123
|
}
|
|
124
|
+
return replyUnavailable(request, reply, { name: appName, status: 'missing' });
|
|
86
125
|
}
|
|
87
126
|
});
|
|
88
127
|
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const fp = require('fastify-plugin');
|
|
2
|
+
|
|
3
|
+
module.exports = fp(async (fastify, options) => {
|
|
4
|
+
const { services } = fastify[options.name];
|
|
5
|
+
const auth = () => options.createAuthenticate();
|
|
6
|
+
|
|
7
|
+
const versionProps = {
|
|
8
|
+
name: { type: 'string' },
|
|
9
|
+
versionId: { type: 'string' }
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
fastify.get(
|
|
13
|
+
`${options.prefix}/app/version/migration/list`,
|
|
14
|
+
{
|
|
15
|
+
onRequest: auth(),
|
|
16
|
+
schema: {
|
|
17
|
+
summary: '版本迁移脚本列表(含执行状态)',
|
|
18
|
+
query: {
|
|
19
|
+
type: 'object',
|
|
20
|
+
required: ['name', 'versionId'],
|
|
21
|
+
properties: versionProps
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
async request => services.migration.list(request.query)
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
fastify.get(
|
|
29
|
+
`${options.prefix}/app/version/migration/content`,
|
|
30
|
+
{
|
|
31
|
+
onRequest: auth(),
|
|
32
|
+
schema: {
|
|
33
|
+
summary: '读取迁移脚本内容',
|
|
34
|
+
query: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
required: ['name', 'versionId', 'file'],
|
|
37
|
+
properties: Object.assign({}, versionProps, { file: { type: 'string' } })
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
async request => services.migration.content(request.query)
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
fastify.post(
|
|
45
|
+
`${options.prefix}/app/version/migration/save`,
|
|
46
|
+
{
|
|
47
|
+
onRequest: auth(),
|
|
48
|
+
schema: {
|
|
49
|
+
summary: '新增或覆盖迁移脚本',
|
|
50
|
+
body: {
|
|
51
|
+
type: 'object',
|
|
52
|
+
required: ['name', 'versionId', 'file', 'content'],
|
|
53
|
+
properties: Object.assign({}, versionProps, {
|
|
54
|
+
file: { type: 'string' },
|
|
55
|
+
content: { type: 'string' }
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
async request => services.migration.save(request.body)
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
fastify.post(
|
|
64
|
+
`${options.prefix}/app/version/migration/remove`,
|
|
65
|
+
{
|
|
66
|
+
onRequest: auth(),
|
|
67
|
+
schema: {
|
|
68
|
+
summary: '删除迁移脚本文件(不改执行记录)',
|
|
69
|
+
body: {
|
|
70
|
+
type: 'object',
|
|
71
|
+
required: ['name', 'versionId', 'file'],
|
|
72
|
+
properties: Object.assign({}, versionProps, { file: { type: 'string' } })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
async request => services.migration.remove(request.body)
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
fastify.post(
|
|
80
|
+
`${options.prefix}/app/version/migration/action`,
|
|
81
|
+
{
|
|
82
|
+
onRequest: auth(),
|
|
83
|
+
schema: {
|
|
84
|
+
summary: '执行 / 标记已执行 / 撤销执行记录',
|
|
85
|
+
body: {
|
|
86
|
+
type: 'object',
|
|
87
|
+
required: ['name', 'versionId', 'file', 'action'],
|
|
88
|
+
properties: Object.assign({}, versionProps, {
|
|
89
|
+
file: { type: 'string' },
|
|
90
|
+
action: { type: 'string', enum: ['execute', 'mark', 'unmark'] }
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
async request => services.migration.action(request.body)
|
|
96
|
+
);
|
|
97
|
+
});
|
package/libs/services/app.js
CHANGED
|
@@ -7,7 +7,7 @@ const { mergeEnv, maskEnvForResponse, applyEnvPatch, mergePm2Config, normalizeSe
|
|
|
7
7
|
const { prepareVersionArtifact } = require('../utils/version');
|
|
8
8
|
const { injectEntryHtml } = require('../utils/entryInject');
|
|
9
9
|
const { ensureLogFiles, readLogTail, readLastLines, appendLog } = require('../utils/logFiles');
|
|
10
|
-
const { runSqlMigrations } = require('../utils/migrate');
|
|
10
|
+
const { runSqlMigrations, MIGRATION_ACTIONS } = require('../utils/migrate');
|
|
11
11
|
const { listSqlFiles } = require('../utils/validatePackage');
|
|
12
12
|
const pm2Util = require('../utils/pm2');
|
|
13
13
|
|
|
@@ -458,7 +458,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
458
458
|
});
|
|
459
459
|
};
|
|
460
460
|
|
|
461
|
-
const deploy = async ({ name, versionId, version, runMigration = true }) => {
|
|
461
|
+
const deploy = async ({ name, versionId, version, runMigration = true, migrations }) => {
|
|
462
462
|
const app = await getByName(name);
|
|
463
463
|
let ver = null;
|
|
464
464
|
if (versionId) {
|
|
@@ -499,11 +499,18 @@ module.exports = fp(async (fastify, options) => {
|
|
|
499
499
|
} catch (e) {
|
|
500
500
|
beforeTables = [];
|
|
501
501
|
}
|
|
502
|
+
const actions = {};
|
|
503
|
+
for (const item of Array.isArray(migrations) ? migrations : []) {
|
|
504
|
+
if (item?.name && MIGRATION_ACTIONS.includes(item.action)) {
|
|
505
|
+
actions[item.name] = item.action;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
502
508
|
await runSqlMigrations({
|
|
503
509
|
serverDir: path.join(ver.artifactPath, 'server'),
|
|
504
510
|
sqlPath: ver.migrationPath || options.sqlPath,
|
|
505
511
|
env,
|
|
506
|
-
Sequelize: fastify.sequelize.Sequelize
|
|
512
|
+
Sequelize: fastify.sequelize.Sequelize,
|
|
513
|
+
actions
|
|
507
514
|
});
|
|
508
515
|
try {
|
|
509
516
|
const afterTables = await dbops.withSequelize(app, async sequelize => dbops.listAllTables(sequelize));
|
|
@@ -628,16 +635,16 @@ module.exports = fp(async (fastify, options) => {
|
|
|
628
635
|
return readLogTail(file, { perPage, currentPage, beforeLine });
|
|
629
636
|
};
|
|
630
637
|
|
|
631
|
-
const findByHost = async host => {
|
|
638
|
+
const findByHost = async (host, { running = true } = {}) => {
|
|
632
639
|
if (!host) {
|
|
633
640
|
return null;
|
|
634
641
|
}
|
|
635
642
|
const hostname = String(host).split(':')[0].toLowerCase();
|
|
636
|
-
return models.app.findOne({ where: { domain: hostname, status: 'running' } });
|
|
643
|
+
return models.app.findOne({ where: Object.assign({ domain: hostname }, running ? { status: 'running' } : {}) });
|
|
637
644
|
};
|
|
638
645
|
|
|
639
|
-
const findByPathName = async name => {
|
|
640
|
-
return models.app.findOne({ where: { name, status: 'running' } });
|
|
646
|
+
const findByPathName = async (name, { running = true } = {}) => {
|
|
647
|
+
return models.app.findOne({ where: Object.assign({ name }, running ? { status: 'running' } : {}) });
|
|
641
648
|
};
|
|
642
649
|
|
|
643
650
|
Object.assign(fastify[options.name].services, {
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
const fp = require('fastify-plugin');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const { listSqlFiles } = require('../utils/validatePackage');
|
|
5
|
+
const { isValidSqlFileName, readExecutedMigrations, recordMigration, removeMigrationRecord } = require('../utils/migrate');
|
|
6
|
+
|
|
7
|
+
module.exports = fp(async (fastify, options) => {
|
|
8
|
+
const { models } = fastify[options.name];
|
|
9
|
+
const getDbops = () => fastify[options.name].services.dbops;
|
|
10
|
+
|
|
11
|
+
const httpError = (statusCode, message) => {
|
|
12
|
+
if (fastify.httpErrors) {
|
|
13
|
+
if (statusCode === 404) throw fastify.httpErrors.notFound(message);
|
|
14
|
+
if (statusCode === 400) throw fastify.httpErrors.badRequest(message);
|
|
15
|
+
}
|
|
16
|
+
const err = new Error(message);
|
|
17
|
+
err.statusCode = statusCode;
|
|
18
|
+
throw err;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const resolveVersion = async ({ name, versionId }) => {
|
|
22
|
+
const app = await models.app.findOne({ where: { name } });
|
|
23
|
+
if (!app) {
|
|
24
|
+
httpError(404, `app ${name} not found`);
|
|
25
|
+
}
|
|
26
|
+
const ver = versionId ? await models.appVersion.findByPk(versionId) : null;
|
|
27
|
+
if (!ver || ver.appName !== name) {
|
|
28
|
+
httpError(404, 'version not found');
|
|
29
|
+
}
|
|
30
|
+
const sqlDir = path.join(ver.artifactPath, 'server', ver.migrationPath || options.sqlPath);
|
|
31
|
+
return { app, ver, sqlDir };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const assertFileName = file => {
|
|
35
|
+
if (!isValidSqlFileName(file)) {
|
|
36
|
+
httpError(400, 'invalid sql file name: use [A-Za-z0-9_-.] and end with .sql');
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const assertFileExists = async (sqlDir, file) => {
|
|
41
|
+
assertFileName(file);
|
|
42
|
+
const filePath = path.join(sqlDir, file);
|
|
43
|
+
if (!(await fs.pathExists(filePath))) {
|
|
44
|
+
httpError(404, `sql file ${file} not found`);
|
|
45
|
+
}
|
|
46
|
+
return filePath;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const syncHasMigration = async (ver, sqlDir) => {
|
|
50
|
+
const hasMigration = (await listSqlFiles(sqlDir)).length > 0;
|
|
51
|
+
if (ver.hasMigration !== hasMigration) {
|
|
52
|
+
await ver.update({ hasMigration });
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const list = async ({ name, versionId }) => {
|
|
57
|
+
const { app, ver, sqlDir } = await resolveVersion({ name, versionId });
|
|
58
|
+
const files = await listSqlFiles(sqlDir);
|
|
59
|
+
let executedMap = new Map();
|
|
60
|
+
let dbError = null;
|
|
61
|
+
try {
|
|
62
|
+
executedMap = await getDbops().withSequelize(app, sequelize => readExecutedMigrations(sequelize));
|
|
63
|
+
} catch (e) {
|
|
64
|
+
dbError = e.message;
|
|
65
|
+
}
|
|
66
|
+
const pageData = await Promise.all(
|
|
67
|
+
files.map(async file => {
|
|
68
|
+
const stat = await fs.stat(path.join(sqlDir, file));
|
|
69
|
+
return {
|
|
70
|
+
name: file,
|
|
71
|
+
size: stat.size,
|
|
72
|
+
updatedAt: stat.mtime,
|
|
73
|
+
executed: dbError ? null : executedMap.has(file),
|
|
74
|
+
executedAt: executedMap.get(file) || null
|
|
75
|
+
};
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
return {
|
|
79
|
+
versionId: String(ver.id),
|
|
80
|
+
version: ver.version,
|
|
81
|
+
migrationPath: ver.migrationPath || options.sqlPath,
|
|
82
|
+
migrateBeforeStart: !!options.migrateBeforeStart,
|
|
83
|
+
dbError,
|
|
84
|
+
pageData,
|
|
85
|
+
totalCount: pageData.length
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const content = async ({ name, versionId, file }) => {
|
|
90
|
+
const { sqlDir } = await resolveVersion({ name, versionId });
|
|
91
|
+
const filePath = await assertFileExists(sqlDir, file);
|
|
92
|
+
return { name: file, content: await fs.readFile(filePath, 'utf8') };
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const save = async ({ name, versionId, file, content: sql }) => {
|
|
96
|
+
assertFileName(file);
|
|
97
|
+
const { ver, sqlDir } = await resolveVersion({ name, versionId });
|
|
98
|
+
await fs.ensureDir(sqlDir);
|
|
99
|
+
await fs.writeFile(path.join(sqlDir, file), sql || '', 'utf8');
|
|
100
|
+
await syncHasMigration(ver, sqlDir);
|
|
101
|
+
return { name: file };
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const remove = async ({ name, versionId, file }) => {
|
|
105
|
+
const { ver, sqlDir } = await resolveVersion({ name, versionId });
|
|
106
|
+
const filePath = await assertFileExists(sqlDir, file);
|
|
107
|
+
await fs.remove(filePath);
|
|
108
|
+
await syncHasMigration(ver, sqlDir);
|
|
109
|
+
return { name: file };
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const execute = async ({ name, versionId, file }) => {
|
|
113
|
+
const { app, sqlDir } = await resolveVersion({ name, versionId });
|
|
114
|
+
const filePath = await assertFileExists(sqlDir, file);
|
|
115
|
+
const sql = await fs.readFile(filePath, 'utf8');
|
|
116
|
+
const dbops = getDbops();
|
|
117
|
+
let beforeTables = [];
|
|
118
|
+
let afterTables = [];
|
|
119
|
+
await dbops.withSequelize(app, async sequelize => {
|
|
120
|
+
beforeTables = await dbops.listAllTables(sequelize).catch(() => []);
|
|
121
|
+
await sequelize.query(sql);
|
|
122
|
+
await recordMigration(sequelize, file);
|
|
123
|
+
afterTables = await dbops.listAllTables(sequelize).catch(() => []);
|
|
124
|
+
});
|
|
125
|
+
try {
|
|
126
|
+
await dbops.mergeOwnedTablesFromDiff(app, beforeTables, afterTables);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
fastify.log.warn({ err: e, app: name, file }, 'merge owned tables after migration failed');
|
|
129
|
+
}
|
|
130
|
+
return { name: file, executed: true };
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const mark = async ({ name, versionId, file }) => {
|
|
134
|
+
const { app, sqlDir } = await resolveVersion({ name, versionId });
|
|
135
|
+
await assertFileExists(sqlDir, file);
|
|
136
|
+
await getDbops().withSequelize(app, sequelize => recordMigration(sequelize, file));
|
|
137
|
+
return { name: file, executed: true };
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const unmark = async ({ name, versionId, file }) => {
|
|
141
|
+
assertFileName(file);
|
|
142
|
+
const { app } = await resolveVersion({ name, versionId });
|
|
143
|
+
await getDbops().withSequelize(app, sequelize => removeMigrationRecord(sequelize, file));
|
|
144
|
+
return { name: file, executed: false };
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const action = async ({ action: type, ...props }) => {
|
|
148
|
+
if (type === 'execute') return execute(props);
|
|
149
|
+
if (type === 'mark') return mark(props);
|
|
150
|
+
if (type === 'unmark') return unmark(props);
|
|
151
|
+
httpError(400, `unknown action ${type}`);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
Object.assign(fastify[options.name].services, {
|
|
155
|
+
migration: {
|
|
156
|
+
list,
|
|
157
|
+
content,
|
|
158
|
+
save,
|
|
159
|
+
remove,
|
|
160
|
+
execute,
|
|
161
|
+
mark,
|
|
162
|
+
unmark,
|
|
163
|
+
action
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
package/libs/utils/migrate.js
CHANGED
|
@@ -3,44 +3,80 @@ const fs = require('fs-extra');
|
|
|
3
3
|
const { listSqlFiles } = require('./validatePackage');
|
|
4
4
|
|
|
5
5
|
const SQL_MIGRATIONS_TABLE = '_fs_sql_migrations';
|
|
6
|
+
const MIGRATION_ACTIONS = ['execute', 'skip', 'hold'];
|
|
7
|
+
const SQL_FILE_NAME_RE = /^[A-Za-z0-9_\-.]+\.sql$/;
|
|
8
|
+
|
|
9
|
+
const isValidSqlFileName = name => typeof name === 'string' && SQL_FILE_NAME_RE.test(name) && !name.includes('..');
|
|
10
|
+
|
|
11
|
+
const ensureMigrationsTable = sequelize =>
|
|
12
|
+
// TIMESTAMP is portable across sqlite / postgres / mysql (avoid DATETIME — missing on PG)
|
|
13
|
+
sequelize.query(
|
|
14
|
+
`CREATE TABLE IF NOT EXISTS ${SQL_MIGRATIONS_TABLE} (
|
|
15
|
+
name VARCHAR(255) PRIMARY KEY,
|
|
16
|
+
executed_at TIMESTAMP
|
|
17
|
+
)`
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const readExecutedMigrations = async sequelize => {
|
|
21
|
+
await ensureMigrationsTable(sequelize);
|
|
22
|
+
const [rows] = await sequelize.query(`SELECT name, executed_at FROM ${SQL_MIGRATIONS_TABLE}`);
|
|
23
|
+
return new Map((rows || []).map(r => [r.name, r.executed_at || null]));
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const recordMigration = async (sequelize, name) => {
|
|
27
|
+
await ensureMigrationsTable(sequelize);
|
|
28
|
+
await sequelize.query(`DELETE FROM ${SQL_MIGRATIONS_TABLE} WHERE name = ?`, { replacements: [name] });
|
|
29
|
+
await sequelize.query(`INSERT INTO ${SQL_MIGRATIONS_TABLE} (name, executed_at) VALUES (?, ?)`, {
|
|
30
|
+
replacements: [name, new Date().toISOString()]
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const removeMigrationRecord = async (sequelize, name) => {
|
|
35
|
+
await ensureMigrationsTable(sequelize);
|
|
36
|
+
await sequelize.query(`DELETE FROM ${SQL_MIGRATIONS_TABLE} WHERE name = ?`, { replacements: [name] });
|
|
37
|
+
};
|
|
6
38
|
|
|
7
39
|
/**
|
|
8
40
|
* Run .sql files under server/{sqlPath} with the same tracking semantics as @kne/fastify-sequelize.
|
|
41
|
+
* actions: { [file]: 'execute' | 'skip' | 'hold' } for pending files; missing entries default to 'execute'.
|
|
42
|
+
* skip records the file as executed without running it (so the child app won't run it on sync either);
|
|
43
|
+
* hold leaves it pending.
|
|
9
44
|
*/
|
|
10
|
-
const runSqlMigrations = async ({ serverDir, sqlPath = 'sql', env = {}, Sequelize: SequelizeCtor } = {}) => {
|
|
45
|
+
const runSqlMigrations = async ({ serverDir, sqlPath = 'sql', env = {}, Sequelize: SequelizeCtor, actions = {} } = {}) => {
|
|
11
46
|
const sqlDir = path.join(serverDir, sqlPath);
|
|
12
47
|
const files = await listSqlFiles(sqlDir);
|
|
13
48
|
if (!files.length) {
|
|
14
|
-
return { executed: [], skipped: true };
|
|
49
|
+
return { executed: [], marked: [], held: [], skipped: true };
|
|
15
50
|
}
|
|
16
51
|
|
|
17
52
|
const sequelize = createSequelizeFromEnv(env, SequelizeCtor);
|
|
18
53
|
try {
|
|
19
54
|
await sequelize.authenticate();
|
|
20
|
-
|
|
21
|
-
await sequelize.query(
|
|
22
|
-
`CREATE TABLE IF NOT EXISTS ${SQL_MIGRATIONS_TABLE} (
|
|
23
|
-
name VARCHAR(255) PRIMARY KEY,
|
|
24
|
-
executed_at TIMESTAMP
|
|
25
|
-
)`
|
|
26
|
-
);
|
|
27
|
-
|
|
28
|
-
const [rows] = await sequelize.query(`SELECT name FROM ${SQL_MIGRATIONS_TABLE}`);
|
|
29
|
-
const done = new Set((rows || []).map(r => r.name));
|
|
55
|
+
const done = await readExecutedMigrations(sequelize);
|
|
30
56
|
const executed = [];
|
|
57
|
+
const marked = [];
|
|
58
|
+
const held = [];
|
|
31
59
|
|
|
32
60
|
for (const file of files) {
|
|
33
61
|
if (done.has(file)) {
|
|
34
62
|
continue;
|
|
35
63
|
}
|
|
64
|
+
const action = actions[file] || 'execute';
|
|
65
|
+
if (action === 'hold') {
|
|
66
|
+
held.push(file);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (action === 'skip') {
|
|
70
|
+
await recordMigration(sequelize, file);
|
|
71
|
+
marked.push(file);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
36
74
|
const sql = await fs.readFile(path.join(sqlDir, file), 'utf8');
|
|
37
75
|
await sequelize.query(sql);
|
|
38
|
-
await sequelize
|
|
39
|
-
replacements: [file, new Date().toISOString()]
|
|
40
|
-
});
|
|
76
|
+
await recordMigration(sequelize, file);
|
|
41
77
|
executed.push(file);
|
|
42
78
|
}
|
|
43
|
-
return { executed, skipped: false };
|
|
79
|
+
return { executed, marked, held, skipped: false };
|
|
44
80
|
} finally {
|
|
45
81
|
await sequelize.close();
|
|
46
82
|
}
|
|
@@ -70,6 +106,12 @@ const createSequelizeFromEnv = (env, SequelizeCtor) => {
|
|
|
70
106
|
|
|
71
107
|
module.exports = {
|
|
72
108
|
SQL_MIGRATIONS_TABLE,
|
|
109
|
+
MIGRATION_ACTIONS,
|
|
110
|
+
isValidSqlFileName,
|
|
111
|
+
ensureMigrationsTable,
|
|
112
|
+
readExecutedMigrations,
|
|
113
|
+
recordMigration,
|
|
114
|
+
removeMigrationRecord,
|
|
73
115
|
runSqlMigrations,
|
|
74
116
|
createSequelizeFromEnv
|
|
75
117
|
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const STATUS_TEXT = {
|
|
2
|
+
stopped: { title: '应用已停止', desc: '该应用当前处于停止状态,请联系管理员启动后再访问。' },
|
|
3
|
+
deploying: { title: '应用正在启动', desc: '应用正在部署或启动中,页面将自动刷新。' },
|
|
4
|
+
error: { title: '应用运行异常', desc: '应用启动失败或运行异常,请联系管理员处理。' },
|
|
5
|
+
idle: { title: '应用尚未部署', desc: '该应用还没有部署任何版本,请联系管理员部署后再访问。' },
|
|
6
|
+
missing: { title: '应用不存在', desc: '没有找到这个应用,请检查访问地址是否正确。' }
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const STATUS_COLOR = {
|
|
10
|
+
deploying: { background: '#e6f4ff', color: '#1677ff' },
|
|
11
|
+
error: { background: '#fff1f0', color: '#f5222d' },
|
|
12
|
+
missing: { background: '#f2f3f5', color: '#8f959e' },
|
|
13
|
+
default: { background: '#fff7e6', color: '#fa8c16' }
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const escapeHtml = value => String(value == null ? '' : value).replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]);
|
|
17
|
+
|
|
18
|
+
const getStatusText = status => STATUS_TEXT[status] || STATUS_TEXT.stopped;
|
|
19
|
+
|
|
20
|
+
const wantsHtml = request => {
|
|
21
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
if (request.headers['x-requested-with']) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
return String(request.headers.accept || '').includes('text/html');
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** app: { name, label?, status } — status `missing` renders the not-found variant. */
|
|
31
|
+
const renderUnavailablePage = app => {
|
|
32
|
+
const { title, desc } = getStatusText(app.status);
|
|
33
|
+
const { background, color } = STATUS_COLOR[app.status] || STATUS_COLOR.default;
|
|
34
|
+
const label = escapeHtml(app.label || app.name);
|
|
35
|
+
const autoRefresh = app.status === 'deploying' ? '<meta http-equiv="refresh" content="5">' : '';
|
|
36
|
+
const action = app.status === 'missing' ? `<a class="btn" href="/">返回首页</a>` : `<button type="button" class="btn" onclick="location.reload()">刷新页面</button>`;
|
|
37
|
+
return `<!DOCTYPE html>
|
|
38
|
+
<html lang="zh-CN">
|
|
39
|
+
<head>
|
|
40
|
+
<meta charset="utf-8">
|
|
41
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
42
|
+
${autoRefresh}
|
|
43
|
+
<title>${escapeHtml(title)} - ${label}</title>
|
|
44
|
+
<style>
|
|
45
|
+
* { box-sizing: border-box; }
|
|
46
|
+
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px;
|
|
47
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
|
48
|
+
background: #f5f7fa; color: #1f2329; }
|
|
49
|
+
.card { width: 100%; max-width: 440px; padding: 40px 32px; text-align: center; background: #fff; border-radius: 12px;
|
|
50
|
+
box-shadow: 0 4px 24px rgba(31, 35, 41, 0.08); }
|
|
51
|
+
.icon { width: 56px; height: 56px; margin: 0 auto 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
|
52
|
+
background: ${background}; color: ${color}; font-size: 28px; font-weight: 600; }
|
|
53
|
+
h1 { margin: 0 0 8px; font-size: 20px; font-weight: 600; }
|
|
54
|
+
.app { margin: 0 0 16px; color: #646a73; font-size: 14px; word-break: break-all; }
|
|
55
|
+
.desc { margin: 0 0 28px; color: #8f959e; font-size: 14px; line-height: 1.7; }
|
|
56
|
+
.btn { display: inline-block; padding: 8px 24px; border: 0; border-radius: 6px; background: #1677ff; color: #fff; font-size: 14px;
|
|
57
|
+
text-decoration: none; cursor: pointer; }
|
|
58
|
+
.btn:hover { background: #4096ff; }
|
|
59
|
+
</style>
|
|
60
|
+
</head>
|
|
61
|
+
<body>
|
|
62
|
+
<div class="card">
|
|
63
|
+
<div class="icon">${app.status === 'deploying' ? '↻' : app.status === 'missing' ? '?' : '!'}</div>
|
|
64
|
+
<h1>${escapeHtml(title)}</h1>
|
|
65
|
+
<p class="app">${label}</p>
|
|
66
|
+
<p class="desc">${escapeHtml(desc)}</p>
|
|
67
|
+
${action}
|
|
68
|
+
</div>
|
|
69
|
+
</body>
|
|
70
|
+
</html>`;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
module.exports = {
|
|
74
|
+
getStatusText,
|
|
75
|
+
wantsHtml,
|
|
76
|
+
renderUnavailablePage
|
|
77
|
+
};
|