@kne/fastify-app-manager 0.1.6 → 0.1.8
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 +69 -5
- package/index.js +7 -0
- package/libs/controllers/app.js +39 -1
- package/libs/controllers/gateway.js +36 -5
- package/libs/controllers/migration.js +97 -0
- package/libs/services/app.js +59 -9
- 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
|
|
|
@@ -192,6 +193,7 @@ await fastify.listen({ port: 3000 });
|
|
|
192
193
|
| `dbQueryMaxRows` | number | 否 | `500` | 只读 query SQL 最大返回行数 |
|
|
193
194
|
| `pm2Defaults` | object | 否 | 见下表 | 全局 PM2 默认;可被应用 `pm2Config` 覆盖 |
|
|
194
195
|
| `createAuthenticate` | function | 否 | 见说明 | 返回 `onRequest` 钩子数组;默认尝试 admin |
|
|
196
|
+
| `createUserAuthenticate` | function | 否 | 见说明 | 应用中心等普通用户接口的 `onRequest` 钩子数组;默认尝试 `fastify.account.authenticate.user` |
|
|
195
197
|
| `healthCheckPath` | string | 否 | `'/'` | 部署后就绪探测路径 |
|
|
196
198
|
| `healthCheckTimeoutMs` | number | 否 | `30000` | 探测总超时(毫秒) |
|
|
197
199
|
| `healthCheckIntervalMs` | number | 否 | `1000` | 探测轮询间隔(毫秒) |
|
|
@@ -239,11 +241,13 @@ await fastify.listen({ port: 3000 });
|
|
|
239
241
|
| domain | string | 否 | - | 绑定 Host(唯一);冲突返回 409 |
|
|
240
242
|
| icon | string | 否 | - | 图标 |
|
|
241
243
|
| description | string | 否 | - | 描述 |
|
|
244
|
+
| category | object / string | 否 | - | 应用分类(应用中心分组用),写入 `options.category`;推荐传 fastify-group 分组对象,对象只保留 `{ code, name }`,字符串原样保存 |
|
|
245
|
+
| isPublic | boolean | 否 | `true` | 是否在公开应用中心展示,写入 `options.isPublic`;未设置视为公开 |
|
|
242
246
|
| env | object | 否 | `{}` | 应用自有环境变量 |
|
|
243
247
|
| pm2Config | object | 否 | `{}` | PM2 覆盖项 |
|
|
244
248
|
| options | object | 否 | `{}` | 扩展字段;可含 `secretEnvKeys: string[]` 显式密钥键名 |
|
|
245
249
|
|
|
246
|
-
返回脱敏后的应用对象(含 `port`、`pathUrl`、`passthroughEnvKeys`、`secretEnvKeys`、`status: 'idle'` 等)。
|
|
250
|
+
返回脱敏后的应用对象(含 `port`、`pathUrl`、`category`、`passthroughEnvKeys`、`secretEnvKeys`、`status: 'idle'` 等)。
|
|
247
251
|
|
|
248
252
|
##### POST `{prefix}/app/save`
|
|
249
253
|
|
|
@@ -253,6 +257,8 @@ await fastify.listen({ port: 3000 });
|
|
|
253
257
|
|--------|------|------|------|
|
|
254
258
|
| name | string | 是 | 应用 slug |
|
|
255
259
|
| label / domain / icon / description / env / pm2Config / options | - | 否 | 传入的字段才更新;`env` 按 patch 语义合并 |
|
|
260
|
+
| category | object / string / null | 否 | 合并写入 `options.category`(对象只保留 `{ code, name }`),不影响 `options` 其它键;传空值清除分类 |
|
|
261
|
+
| isPublic | boolean | 否 | 合并写入 `options.isPublic`,不影响 `options` 其它键 |
|
|
256
262
|
|
|
257
263
|
##### POST `{prefix}/app/save-env`
|
|
258
264
|
|
|
@@ -350,6 +356,15 @@ await fastify.listen({ port: 3000 });
|
|
|
350
356
|
| versionId | string | 否 | - | 版本主键;与 `version` 二选一 |
|
|
351
357
|
| version | string | 否 | - | 版本号 |
|
|
352
358
|
| runMigration | boolean | 否 | `true` | false 时向 app.env 写入 `RUN_SQL_ON_SYNC=false`;预跑 SQL 仍受 `migrateBeforeStart` 约束 |
|
|
359
|
+
| migrations | array | 否 | - | `[{ name, action }]` 逐个指定**待执行**脚本的处理方式;未列出的按 `execute`。仅在 `runMigration` 且 `migrateBeforeStart` 时生效 |
|
|
360
|
+
|
|
361
|
+
`migrations[].action`:
|
|
362
|
+
|
|
363
|
+
| 值 | 行为 |
|
|
364
|
+
|----|------|
|
|
365
|
+
| `execute` | 执行脚本并写入 `_fs_sql_migrations` |
|
|
366
|
+
| `skip` | 不执行,只写入执行记录(之后部署和子应用 sync 都不会再执行) |
|
|
367
|
+
| `hold` | 本次不执行也不记账,保持待执行;子应用若启用 `@kne/fastify-sequelize` 的 `runSqlOnSync`,启动时仍可能执行 |
|
|
353
368
|
|
|
354
369
|
返回示例:
|
|
355
370
|
|
|
@@ -364,6 +379,18 @@ await fastify.listen({ port: 3000 });
|
|
|
364
379
|
}
|
|
365
380
|
```
|
|
366
381
|
|
|
382
|
+
##### 版本迁移脚本管理
|
|
383
|
+
|
|
384
|
+
管理某版本 `server/{migrationPath}` 下的 `.sql` 文件,执行状态读写应用库的 `_fs_sql_migrations`(与 `@kne/fastify-sequelize` 共用,按文件名记账;共享库中不同应用的同名脚本会互相影响)。文件名须匹配 `[A-Za-z0-9_-.]+\.sql`。
|
|
385
|
+
|
|
386
|
+
| 方法 | 路径 | 参数 | 说明 |
|
|
387
|
+
|------|------|------|------|
|
|
388
|
+
| GET | `{prefix}/app/version/migration/list` | `name`, `versionId` | 脚本列表:`name`、`size`、`updatedAt`、`executed`、`executedAt`;另返回 `migrateBeforeStart`,库连接失败时 `dbError` 有值且 `executed` 为 null |
|
|
389
|
+
| GET | `{prefix}/app/version/migration/content` | `name`, `versionId`, `file` | 返回 `{ name, content }` |
|
|
390
|
+
| POST | `{prefix}/app/version/migration/save` | `name`, `versionId`, `file`, `content` | 新增或覆盖脚本文件,并同步版本 `hasMigration` |
|
|
391
|
+
| POST | `{prefix}/app/version/migration/remove` | `name`, `versionId`, `file` | 删除脚本文件(不改执行记录),并同步 `hasMigration` |
|
|
392
|
+
| POST | `{prefix}/app/version/migration/action` | `name`, `versionId`, `file`, `action` | `execute` 立即执行并记账(执行后按表差异合并归属表);`mark` 只记账;`unmark` 删除执行记录 |
|
|
393
|
+
|
|
367
394
|
##### GET `{prefix}/app/list`
|
|
368
395
|
|
|
369
396
|
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
|
@@ -375,6 +402,31 @@ await fastify.listen({ port: 3000 });
|
|
|
375
402
|
|
|
376
403
|
返回 `{ pageData, totalCount }`,元素均为脱敏后的应用对象。
|
|
377
404
|
|
|
405
|
+
##### GET `{prefix}/app/center/list`
|
|
406
|
+
|
|
407
|
+
应用中心列表,使用 `createUserAuthenticate` 鉴权(普通登录用户可访问)。仅返回 `status: 'running'` 的应用,且只含公开字段(不含 env、端口、路径等)。
|
|
408
|
+
|
|
409
|
+
```json
|
|
410
|
+
{
|
|
411
|
+
"pageData": [
|
|
412
|
+
{
|
|
413
|
+
"name": "demo",
|
|
414
|
+
"label": "Demo",
|
|
415
|
+
"icon": "file-id",
|
|
416
|
+
"description": "d",
|
|
417
|
+
"category": { "code": "tools", "name": "工具" },
|
|
418
|
+
"isPublic": true,
|
|
419
|
+
"pathUrl": "/app/demo/"
|
|
420
|
+
}
|
|
421
|
+
],
|
|
422
|
+
"totalCount": 1
|
|
423
|
+
}
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
##### GET `{prefix}/app/center/public-list`
|
|
427
|
+
|
|
428
|
+
公开应用中心列表,**无需登录**。返回结构同 `app/center/list`,仅包含运行中且 `isPublic` 不为 `false` 的应用。
|
|
429
|
+
|
|
378
430
|
##### GET `{prefix}/app/detail`
|
|
379
431
|
|
|
380
432
|
| 参数名 | 类型 | 必填 | 说明 |
|
|
@@ -449,14 +501,14 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
|
|
|
449
501
|
| `detail({ name })` | 详情(脱敏) |
|
|
450
502
|
| `uploadVersion({ name, version, label, zipBuffer })` | 上传版本 |
|
|
451
503
|
| `listVersions({ name, perPage, currentPage })` | 版本列表 |
|
|
452
|
-
| `deploy({ name, versionId, version, runMigration })` | 部署;立即 `deploying`,后台 `syncRealStatus` |
|
|
504
|
+
| `deploy({ name, versionId, version, runMigration, migrations? })` | 部署;立即 `deploying`,后台 `syncRealStatus` |
|
|
453
505
|
| `start` / `stop` / `restart` | 生命周期;`start`/`restart` 立即 `deploying`,后台按 PM2+health 写回真实状态 |
|
|
454
506
|
| `syncStatus({ name, recoverIfMissing? })` | 同步单个应用真实状态并返回公开对象 |
|
|
455
507
|
| `syncAllStatuses({ recoverIfMissing? })` | 批量同步(父应用启动 reconcile 使用) |
|
|
456
508
|
| `remove({ name, exportBeforeRemove?, cleanupData?, ... })` | 删除;默认可导出+清理所属表 |
|
|
457
509
|
| `logs({ name, stream, perPage, currentPage })` | 读日志文件 |
|
|
458
|
-
| `findByHost(host)` | 网关:按 domain
|
|
459
|
-
| `findByPathName(name)` | 网关:按 name
|
|
510
|
+
| `findByHost(host, { running? })` | 网关:按 domain 查找;默认只查 `running`,`running: false` 时不限状态 |
|
|
511
|
+
| `findByPathName(name, { running? })` | 网关:按 name 查找;默认只查 `running`,`running: false` 时不限状态 |
|
|
460
512
|
| `appendAppLog(appName, stream, content)` | 追加日志并 emit hub 事件 |
|
|
461
513
|
| `readLastLines(name, stream, n)` | SSE 回放用 |
|
|
462
514
|
| `getLogHub()` | 获取 EventEmitter |
|
|
@@ -471,6 +523,16 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
|
|
|
471
523
|
| `runQuery({ name, sql, replacements? })` | 只读 SQL |
|
|
472
524
|
| `exportData` / `cleanup` | 导出与清理 |
|
|
473
525
|
|
|
526
|
+
##### services.migration
|
|
527
|
+
|
|
528
|
+
| 方法签名 | 说明 |
|
|
529
|
+
|----------|------|
|
|
530
|
+
| `list({ name, versionId })` | 版本脚本及执行状态 |
|
|
531
|
+
| `content({ name, versionId, file })` | 读脚本内容 |
|
|
532
|
+
| `save({ name, versionId, file, content })` / `remove({ name, versionId, file })` | 新增覆盖 / 删除脚本文件 |
|
|
533
|
+
| `execute` / `mark` / `unmark`(`{ name, versionId, file }`) | 执行并记账 / 只记账 / 删除记录 |
|
|
534
|
+
| `action({ name, versionId, file, action })` | 按 `action` 分发到上面三个方法 |
|
|
535
|
+
|
|
474
536
|
##### services.bootstrap
|
|
475
537
|
|
|
476
538
|
| 方法签名 | 说明 |
|
|
@@ -495,7 +557,7 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
|
|
|
495
557
|
| description | string / null | 描述 |
|
|
496
558
|
| env | object | 自有环境变量 |
|
|
497
559
|
| pm2Config | object | PM2 覆盖 |
|
|
498
|
-
| options | object | 扩展字段;`secretEnvKeys`
|
|
560
|
+
| options | object | 扩展字段;`secretEnvKeys` 为显式密钥键名列表,`category` 为应用分类,`isPublic` 为是否公开(未设置视为公开) |
|
|
499
561
|
| port | number | 分配端口 |
|
|
500
562
|
| status | string | `idle` / `deploying` / `running` / `stopped` / `error` |
|
|
501
563
|
| currentVersionId | string / null | 当前部署版本 id |
|
|
@@ -526,6 +588,8 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
|
|
|
526
588
|
| 机制 | 说明 |
|
|
527
589
|
|------|------|
|
|
528
590
|
| 路由优先级 | 管理 API(`prefix`)不进网关;先 domain 匹配,再 path 前缀匹配 |
|
|
591
|
+
| 应用未运行 | 命中已存在但非 `running` 的应用时返回 503(`Cache-Control: no-store`、`Retry-After`):浏览器 `GET`/`HEAD` 且 `Accept` 含 `text/html` 返回状态说明页(`deploying` 每 5 秒自动刷新),其余请求返回 JSON `{ statusCode, error, message, appName, appStatus }` |
|
|
592
|
+
| 应用不存在 | `/app/{name}` 下找不到应用时返回 404「应用不存在」页(带返回首页)或 JSON(`appStatus: 'missing'`);`pathPrefix` 之外以及未绑定的域名仍交给宿主路由 |
|
|
529
593
|
| Location | 相对路径前缀补上 `/app/{name}` |
|
|
530
594
|
| Set-Cookie | `Path=/` 改为 `Path=/app/{name}` |
|
|
531
595
|
| Body | 对文本类 Content-Type,将 `"/static/`、`"/api/`、`"/account/` 等前缀改写;有 `content-encoding` 时跳过 |
|
package/index.js
CHANGED
|
@@ -54,6 +54,13 @@ module.exports = fp(
|
|
|
54
54
|
return [fastify.account.authenticate.admin];
|
|
55
55
|
}
|
|
56
56
|
return [];
|
|
57
|
+
},
|
|
58
|
+
/** 普通登录用户可访问的接口(应用中心列表) */
|
|
59
|
+
createUserAuthenticate: () => {
|
|
60
|
+
if (fastify.account?.authenticate?.user) {
|
|
61
|
+
return [fastify.account.authenticate.user];
|
|
62
|
+
}
|
|
63
|
+
return [];
|
|
57
64
|
}
|
|
58
65
|
},
|
|
59
66
|
options
|
package/libs/controllers/app.js
CHANGED
|
@@ -4,6 +4,7 @@ const { createSseReply } = require('../utils/logStream');
|
|
|
4
4
|
module.exports = fp(async (fastify, options) => {
|
|
5
5
|
const { services } = fastify[options.name];
|
|
6
6
|
const auth = () => options.createAuthenticate();
|
|
7
|
+
const userAuth = () => options.createUserAuthenticate();
|
|
7
8
|
|
|
8
9
|
fastify.post(
|
|
9
10
|
`${options.prefix}/app/create`,
|
|
@@ -20,6 +21,8 @@ module.exports = fp(async (fastify, options) => {
|
|
|
20
21
|
domain: { type: 'string' },
|
|
21
22
|
icon: { type: 'string' },
|
|
22
23
|
description: { type: 'string' },
|
|
24
|
+
category: { type: ['object', 'string', 'null'] },
|
|
25
|
+
isPublic: { type: 'boolean' },
|
|
23
26
|
env: { type: 'object' },
|
|
24
27
|
pm2Config: { type: 'object' },
|
|
25
28
|
options: { type: 'object' }
|
|
@@ -45,6 +48,8 @@ module.exports = fp(async (fastify, options) => {
|
|
|
45
48
|
domain: { type: 'string' },
|
|
46
49
|
icon: { type: 'string' },
|
|
47
50
|
description: { type: 'string' },
|
|
51
|
+
category: { type: ['object', 'string', 'null'] },
|
|
52
|
+
isPublic: { type: 'boolean' },
|
|
48
53
|
env: { type: 'object' },
|
|
49
54
|
pm2Config: { type: 'object' },
|
|
50
55
|
options: { type: 'object' }
|
|
@@ -55,6 +60,27 @@ module.exports = fp(async (fastify, options) => {
|
|
|
55
60
|
async request => services.app.save(request.body)
|
|
56
61
|
);
|
|
57
62
|
|
|
63
|
+
fastify.get(
|
|
64
|
+
`${options.prefix}/app/center/list`,
|
|
65
|
+
{
|
|
66
|
+
onRequest: userAuth(),
|
|
67
|
+
schema: {
|
|
68
|
+
summary: '应用中心:运行中应用的公开信息(普通登录用户可访问)'
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
async () => services.app.centerList()
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
fastify.get(
|
|
75
|
+
`${options.prefix}/app/center/public-list`,
|
|
76
|
+
{
|
|
77
|
+
schema: {
|
|
78
|
+
summary: '应用中心:运行中且公开的应用(无需登录)'
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
async () => services.app.centerList({ publicOnly: true })
|
|
82
|
+
);
|
|
83
|
+
|
|
58
84
|
fastify.post(
|
|
59
85
|
`${options.prefix}/app/save-env`,
|
|
60
86
|
{
|
|
@@ -139,7 +165,19 @@ module.exports = fp(async (fastify, options) => {
|
|
|
139
165
|
name: { type: 'string' },
|
|
140
166
|
versionId: { type: 'string' },
|
|
141
167
|
version: { type: 'string' },
|
|
142
|
-
runMigration: { type: 'boolean', default: true }
|
|
168
|
+
runMigration: { type: 'boolean', default: true },
|
|
169
|
+
migrations: {
|
|
170
|
+
type: 'array',
|
|
171
|
+
description: '逐个指定待执行脚本的处理方式;未列出的待执行脚本按 execute 处理',
|
|
172
|
+
items: {
|
|
173
|
+
type: 'object',
|
|
174
|
+
required: ['name', 'action'],
|
|
175
|
+
properties: {
|
|
176
|
+
name: { type: 'string' },
|
|
177
|
+
action: { type: 'string', enum: ['execute', 'skip', 'hold'] }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
143
181
|
}
|
|
144
182
|
}
|
|
145
183
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fp = require('fastify-plugin');
|
|
2
2
|
const { Readable } = require('node:stream');
|
|
3
3
|
const { rewriteLocation, rewriteSetCookiePath, shouldRewriteBody, rewriteBody } = require('../utils/pathRewrite');
|
|
4
|
+
const { wantsHtml, renderUnavailablePage, getStatusText } = require('../utils/unavailablePage');
|
|
4
5
|
|
|
5
6
|
module.exports = fp(async (fastify, options) => {
|
|
6
7
|
const ns = options.name;
|
|
@@ -66,6 +67,35 @@ module.exports = fp(async (fastify, options) => {
|
|
|
66
67
|
});
|
|
67
68
|
};
|
|
68
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
|
+
|
|
69
99
|
// Domain + path gateway: run early, but skip management API and let other routes try first for non-matches.
|
|
70
100
|
fastify.addHook('onRequest', async (request, reply) => {
|
|
71
101
|
const url = request.raw.url || '';
|
|
@@ -79,18 +109,19 @@ module.exports = fp(async (fastify, options) => {
|
|
|
79
109
|
}
|
|
80
110
|
|
|
81
111
|
const host = request.headers.host;
|
|
82
|
-
const byHost = await services.app.findByHost(host);
|
|
112
|
+
const byHost = await services.app.findByHost(host, { running: false });
|
|
83
113
|
if (byHost) {
|
|
84
|
-
return proxyToApp(request, reply, byHost, { stripPrefix: false });
|
|
114
|
+
return byHost.status === 'running' ? proxyToApp(request, reply, byHost, { stripPrefix: false }) : replyUnavailable(request, reply, byHost);
|
|
85
115
|
}
|
|
86
116
|
|
|
87
117
|
const match = url.match(new RegExp(`^${pathPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/([^/?#]+)`));
|
|
88
118
|
if (match) {
|
|
89
|
-
const appName = match[1];
|
|
90
|
-
const app = await services.app.findByPathName(appName);
|
|
119
|
+
const appName = decodeName(match[1]);
|
|
120
|
+
const app = await services.app.findByPathName(appName, { running: false });
|
|
91
121
|
if (app) {
|
|
92
|
-
return proxyToApp(request, reply, app, { stripPrefix: true });
|
|
122
|
+
return app.status === 'running' ? proxyToApp(request, reply, app, { stripPrefix: true }) : replyUnavailable(request, reply, app);
|
|
93
123
|
}
|
|
124
|
+
return replyUnavailable(request, reply, { name: appName, status: 'missing' });
|
|
94
125
|
}
|
|
95
126
|
});
|
|
96
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
|
|
|
@@ -31,6 +31,18 @@ module.exports = fp(async (fastify, options) => {
|
|
|
31
31
|
const mountPrefix = name => `${options.pathPrefix.replace(/\/$/, '')}/${name}`;
|
|
32
32
|
|
|
33
33
|
const resolveSecretEnvKeys = appLike => normalizeSecretKeys(appLike?.options?.secretEnvKeys);
|
|
34
|
+
// 未设置视为公开(与远程组件默认公开一致)
|
|
35
|
+
const isAppPublic = appLike => appLike?.options?.isPublic !== false;
|
|
36
|
+
// 分组选择器的值可能是整条分组记录(含 id/children/options),只保留 code/name
|
|
37
|
+
const normalizeCategory = category => {
|
|
38
|
+
if (!category) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
if (typeof category !== 'object') {
|
|
42
|
+
return String(category);
|
|
43
|
+
}
|
|
44
|
+
return category.code ? { code: category.code, name: category.name || category.code } : null;
|
|
45
|
+
};
|
|
34
46
|
|
|
35
47
|
const toPublicApp = app => {
|
|
36
48
|
if (!app) {
|
|
@@ -43,6 +55,8 @@ module.exports = fp(async (fastify, options) => {
|
|
|
43
55
|
json.passthroughEnvKeys = options.passthroughEnvKeys || [];
|
|
44
56
|
json.secretEnvKeys = collectSecretKeys(json.env || {}, envOpts);
|
|
45
57
|
json.pathUrl = `${mountPrefix(json.name)}/`;
|
|
58
|
+
json.category = json.options?.category || null;
|
|
59
|
+
json.isPublic = isAppPublic(json);
|
|
46
60
|
return json;
|
|
47
61
|
};
|
|
48
62
|
|
|
@@ -55,7 +69,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
55
69
|
};
|
|
56
70
|
|
|
57
71
|
const create = async data => {
|
|
58
|
-
const { name, label, domain, icon, description, env, pm2Config, options: appOptions } = data;
|
|
72
|
+
const { name, label, domain, icon, description, category, isPublic, env, pm2Config, options: appOptions } = data;
|
|
59
73
|
if (!name || !NAME_RE.test(name)) {
|
|
60
74
|
httpError(400, 'invalid name: use lowercase slug [a-z0-9-]');
|
|
61
75
|
}
|
|
@@ -89,6 +103,12 @@ module.exports = fp(async (fastify, options) => {
|
|
|
89
103
|
if (normalizedOptions.secretEnvKeys !== undefined) {
|
|
90
104
|
normalizedOptions.secretEnvKeys = normalizeSecretKeys(normalizedOptions.secretEnvKeys);
|
|
91
105
|
}
|
|
106
|
+
if (category !== undefined) {
|
|
107
|
+
normalizedOptions.category = normalizeCategory(category);
|
|
108
|
+
}
|
|
109
|
+
if (isPublic !== undefined) {
|
|
110
|
+
normalizedOptions.isPublic = !!isPublic;
|
|
111
|
+
}
|
|
92
112
|
|
|
93
113
|
const app = await models.app.create({
|
|
94
114
|
name,
|
|
@@ -108,7 +128,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
108
128
|
return toPublicApp(app);
|
|
109
129
|
};
|
|
110
130
|
|
|
111
|
-
const save = async ({ name, ...data }) => {
|
|
131
|
+
const save = async ({ name, category, isPublic, ...data }) => {
|
|
112
132
|
const app = await getByName(name);
|
|
113
133
|
const omit = ['name', 'port', 'rootPath', 'pm2Name', 'status', 'currentVersionId'];
|
|
114
134
|
const patch = {};
|
|
@@ -133,6 +153,12 @@ module.exports = fp(async (fastify, options) => {
|
|
|
133
153
|
secretEnvKeys: normalizeSecretKeys(patch.options.secretEnvKeys)
|
|
134
154
|
});
|
|
135
155
|
}
|
|
156
|
+
if (category !== undefined) {
|
|
157
|
+
patch.options = Object.assign({}, patch.options || app.options || {}, { category: normalizeCategory(category) });
|
|
158
|
+
}
|
|
159
|
+
if (isPublic !== undefined) {
|
|
160
|
+
patch.options = Object.assign({}, patch.options || app.options || {}, { isPublic: !!isPublic });
|
|
161
|
+
}
|
|
136
162
|
await app.update(patch);
|
|
137
163
|
return toPublicApp(app);
|
|
138
164
|
};
|
|
@@ -172,6 +198,22 @@ module.exports = fp(async (fastify, options) => {
|
|
|
172
198
|
};
|
|
173
199
|
};
|
|
174
200
|
|
|
201
|
+
const centerList = async ({ publicOnly = false } = {}) => {
|
|
202
|
+
const rows = await models.app.findAll({ where: { status: 'running' }, order: [['createdAt', 'DESC']] });
|
|
203
|
+
const pageData = rows
|
|
204
|
+
.filter(app => !publicOnly || isAppPublic(app))
|
|
205
|
+
.map(app => ({
|
|
206
|
+
name: app.name,
|
|
207
|
+
label: app.label,
|
|
208
|
+
icon: app.icon,
|
|
209
|
+
description: app.description,
|
|
210
|
+
category: app.options?.category || null,
|
|
211
|
+
isPublic: isAppPublic(app),
|
|
212
|
+
pathUrl: `${mountPrefix(app.name)}/`
|
|
213
|
+
}));
|
|
214
|
+
return { pageData, totalCount: pageData.length };
|
|
215
|
+
};
|
|
216
|
+
|
|
175
217
|
const detail = async ({ name }) => {
|
|
176
218
|
const app = await getByName(name);
|
|
177
219
|
return toPublicApp(app);
|
|
@@ -458,7 +500,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
458
500
|
});
|
|
459
501
|
};
|
|
460
502
|
|
|
461
|
-
const deploy = async ({ name, versionId, version, runMigration = true }) => {
|
|
503
|
+
const deploy = async ({ name, versionId, version, runMigration = true, migrations }) => {
|
|
462
504
|
const app = await getByName(name);
|
|
463
505
|
let ver = null;
|
|
464
506
|
if (versionId) {
|
|
@@ -499,11 +541,18 @@ module.exports = fp(async (fastify, options) => {
|
|
|
499
541
|
} catch (e) {
|
|
500
542
|
beforeTables = [];
|
|
501
543
|
}
|
|
544
|
+
const actions = {};
|
|
545
|
+
for (const item of Array.isArray(migrations) ? migrations : []) {
|
|
546
|
+
if (item?.name && MIGRATION_ACTIONS.includes(item.action)) {
|
|
547
|
+
actions[item.name] = item.action;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
502
550
|
await runSqlMigrations({
|
|
503
551
|
serverDir: path.join(ver.artifactPath, 'server'),
|
|
504
552
|
sqlPath: ver.migrationPath || options.sqlPath,
|
|
505
553
|
env,
|
|
506
|
-
Sequelize: fastify.sequelize.Sequelize
|
|
554
|
+
Sequelize: fastify.sequelize.Sequelize,
|
|
555
|
+
actions
|
|
507
556
|
});
|
|
508
557
|
try {
|
|
509
558
|
const afterTables = await dbops.withSequelize(app, async sequelize => dbops.listAllTables(sequelize));
|
|
@@ -628,16 +677,16 @@ module.exports = fp(async (fastify, options) => {
|
|
|
628
677
|
return readLogTail(file, { perPage, currentPage, beforeLine });
|
|
629
678
|
};
|
|
630
679
|
|
|
631
|
-
const findByHost = async host => {
|
|
680
|
+
const findByHost = async (host, { running = true } = {}) => {
|
|
632
681
|
if (!host) {
|
|
633
682
|
return null;
|
|
634
683
|
}
|
|
635
684
|
const hostname = String(host).split(':')[0].toLowerCase();
|
|
636
|
-
return models.app.findOne({ where: { domain: hostname, status: 'running' } });
|
|
685
|
+
return models.app.findOne({ where: Object.assign({ domain: hostname }, running ? { status: 'running' } : {}) });
|
|
637
686
|
};
|
|
638
687
|
|
|
639
|
-
const findByPathName = async name => {
|
|
640
|
-
return models.app.findOne({ where: { name, status: 'running' } });
|
|
688
|
+
const findByPathName = async (name, { running = true } = {}) => {
|
|
689
|
+
return models.app.findOne({ where: Object.assign({ name }, running ? { status: 'running' } : {}) });
|
|
641
690
|
};
|
|
642
691
|
|
|
643
692
|
Object.assign(fastify[options.name].services, {
|
|
@@ -646,6 +695,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
646
695
|
save,
|
|
647
696
|
saveEnv,
|
|
648
697
|
list,
|
|
698
|
+
centerList,
|
|
649
699
|
detail,
|
|
650
700
|
uploadVersion,
|
|
651
701
|
listVersions,
|
|
@@ -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
|
+
};
|