@kne/fastify-app-manager 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +152 -18
- package/index.js +2 -0
- package/libs/controllers/app.js +116 -16
- package/libs/models/appVersion.js +15 -0
- package/libs/services/app.js +479 -42
- package/libs/services/bootstrap.js +68 -5
- package/libs/services/migration.js +34 -46
- package/libs/utils/deployProgress.js +66 -0
- package/libs/utils/env.js +1 -0
- package/libs/utils/envSchema.js +105 -0
- package/libs/utils/validatePackage.js +13 -1
- package/libs/utils/version.js +10 -4
- package/libs/utils/versionSql.js +33 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -51,18 +51,22 @@ Client / Admin UI
|
|
|
51
51
|
| Path gateway | `/app/{name}` 剥前缀;改写绝对路径 Location、Cookie Path、文本类 body |
|
|
52
52
|
| 未运行兜底 | 应用存在但未 `running` 时,网关返回 503 状态页(已停止 / 启动中 / 异常 / 未部署),接口请求返回 JSON,不再落到宿主 404;`/app/{name}` 下应用不存在时返回 404「应用不存在」页 |
|
|
53
53
|
| PM2 | 每应用一进程,名 `app-manager__{name}`;stdout/stderr 由 PM2 落 `appsRoot/{name}/logs`,插件按大小/按天切分归档 |
|
|
54
|
-
| Bootstrap | `onReady` 连接 PM2、挂 log bus(只推 SSE
|
|
54
|
+
| Bootstrap | `onReady` 连接 PM2、挂 log bus(只推 SSE)、启动日志切分与负载采样定时器;**异步**并行:目录完好的托管应用立即 `syncAllStatuses`,缺当前版本目录的应用按版本 `fileId` 逐个补回后再单独拉起,最后清理非当前版本目录(均不阻塞父应用 ready) |
|
|
55
55
|
|
|
56
56
|
##### 部署时序
|
|
57
57
|
|
|
58
58
|
```
|
|
59
|
-
uploadVersion (zip)
|
|
59
|
+
uploadVersion (zip / fileId)
|
|
60
60
|
↓
|
|
61
|
-
校验包格式 →
|
|
61
|
+
校验包格式 → SQL 写入版本记录;multipart 解压并 npm install 后保留目录,fileId 不保留目录
|
|
62
62
|
↓
|
|
63
63
|
deploy(versionId)
|
|
64
64
|
↓
|
|
65
|
-
|
|
65
|
+
目录缺失则按 fileId 解压 + npm install → 数据库 SQL 覆盖写回
|
|
66
|
+
↓
|
|
67
|
+
按 env.schema.json 校验环境变量(不通过即 400,不切版本、不启动)
|
|
68
|
+
↓
|
|
69
|
+
status=deploying → 可选 SQL 迁移 → inject entryHtml → pm2.start → 清理其它版本目录
|
|
66
70
|
↓(立即返回)
|
|
67
71
|
后台 healthCheck → running / error
|
|
68
72
|
```
|
|
@@ -74,7 +78,7 @@ deploy(versionId)
|
|
|
74
78
|
| 概念 | 说明 |
|
|
75
79
|
|------|------|
|
|
76
80
|
| **App** | 元信息、分配端口、env、pm2Config、状态机;落盘目录 `appsRoot/{name}` |
|
|
77
|
-
| **AppVersion** | 一次 zip 上传产物;`artifactPath`
|
|
81
|
+
| **AppVersion** | 一次 zip 上传产物;`artifactPath` 为解压目录(有 `fileId` 的版本只在当前部署时常驻磁盘),SQL 脚本存于 `migrationFiles`;部署时按 id 或 version 选择 |
|
|
78
82
|
| **端口池** | 默认 `4000–7999`,创建时分配未占用端口;`PORT` 强制写入子进程 env |
|
|
79
83
|
|
|
80
84
|
##### 状态机
|
|
@@ -109,6 +113,7 @@ deploy(versionId)
|
|
|
109
113
|
| 应用自有库 | 应用 `env` 含完整 `DB_*` 且 `DB_DEDICATED` 不为 `false` 时为独享库(`dbScope=dedicated`);`DB_DEDICATED=false` 保留配置但回落共享库。`dbScope` 始终由 env 推导,与启动注入的连接一致;独享库若带 `DB_TABLE_PREFIX`(与其它系统共用库)只认该前缀的表 |
|
|
110
114
|
| 共享库表前缀 | 启动时注入 `DB_TABLE_PREFIX=t_{appName}_` 并由系统写回应用 env(接口不返回、不可修改);子应用 sequelize 须读取该变量建表 |
|
|
111
115
|
| 表归属 | `options.ownedTables`;共享库启动就绪后按前缀自动认领,亦可 `sync-owned` 扫描;运维仅限归属表 |
|
|
116
|
+
| 变量声明 | 子应用在 `server/env.schema.json` 声明所需变量(标准 JSON Schema,必填放顶层 `required`,敏感值 `writeOnly`);上传缺失即拒绝,启动前(deploy / start / restart / 恢复)按它校验最终变量;`GET .../app/env/schema` 列出需要用户填写的变量,隐藏 `PORT`、托管库 `DB_*`、系统变量等应用中心注入的键 |
|
|
112
117
|
|
|
113
118
|
##### SQL 迁移
|
|
114
119
|
|
|
@@ -120,6 +125,7 @@ deploy(versionId)
|
|
|
120
125
|
|------|------|
|
|
121
126
|
| 版本上传 | Zip Slip 防护、条目数/体积上限、解压丢弃任意层级 `node_modules`、Fullstack 包格式校验、server 生产依赖安装 |
|
|
122
127
|
| 短部署 | `deploy` / `start` / `restart` 立即返回 `deploying`,后台按 PM2 真实状态 + healthCheck 写回 `running`/`error`/`stopped` |
|
|
128
|
+
| 部署进度 | `deploy` 按「准备部署包 → 校验环境变量 → SQL 迁移 → 启动进程 → 等待就绪」上报阶段,经 `GET .../app/deploy/stream` SSE 推送(含下载 / 解压 / 安装依赖等子阶段) |
|
|
123
129
|
| 生命周期 | `start` / `stop` / `restart` / `remove`;父应用重启后异步 `syncAllStatuses` 对齐 PM2 |
|
|
124
130
|
| 双通道访问 | 自定义域名透明反代 + `/app/{name}` 剥前缀反代 |
|
|
125
131
|
| 日志 | PM2 落盘 + 历史分页(`beforeLine` 上滚、每页最多 100 行)+ SSE(含回放行与心跳) |
|
|
@@ -168,6 +174,7 @@ await fastify.listen({ port: 3000 });
|
|
|
168
174
|
| `package.json` | 根包描述 |
|
|
169
175
|
| `server/package.json` | 服务端依赖 |
|
|
170
176
|
| `server/index.js` | PM2 入口 |
|
|
177
|
+
| `server/env.schema.json` | 环境变量声明(必需);规范见 API 文档「环境变量 schema」 |
|
|
171
178
|
| `build/` | 已构建前端;含 `index.html` / `entry.html` / `entry-prod.html` 之一 |
|
|
172
179
|
| `server/sql/*.sql` | 可选;存在则 `hasMigration=true` |
|
|
173
180
|
|
|
@@ -194,6 +201,7 @@ await fastify.listen({ port: 3000 });
|
|
|
194
201
|
| `systemEnvKeys` | array | 否 | `[]` | 宿主维护的系统变量键名,与内置 `DB_TABLE_PREFIX` 合并:接口不返回,create / save / saveEnv 不可新增、修改或删除 |
|
|
195
202
|
| `resolveSystemEnv` | function | 否 | `null` | `async ({ app, version, serverDir }) => env \| null`,每次启动进程(deploy / start / restart / 恢复)前调用;返回值覆盖应用变量注入子进程,值为 `null` / `undefined` 的键忽略,属于 `systemEnvKeys` 的键持久化到 `app.env` |
|
|
196
203
|
| `onAppRemoved` | function | 否 | `null` | `async ({ app }) => void`,应用删除成功后调用,`app` 为删除前快照(含 `env`);数据清理未完成而未删除时不调用,钩子报错只记 warn 日志 |
|
|
204
|
+
| `resolveVersionFile` | function | 否 | `null` | `async ({ fileId, name, version, request }) => Buffer`,版本上传传 `fileId`(而非 multipart 文件)时调用,返回 zip 内容;未配置时传 `fileId` 返回 400。版本记录保存 `fileId`;这类版本只在当前部署时常驻磁盘,部署非当前版本或启动时当前版本目录缺失会再次调用以重建 |
|
|
197
205
|
| `defaultAppDb` | object | 否 | `appsRoot/_shared/apps-data.sqlite` | 托管应用默认库(须与宿主主库分离) |
|
|
198
206
|
| `dbQueryMaxRows` | number | 否 | `500` | 只读 query SQL 最大返回行数 |
|
|
199
207
|
| `pm2Defaults` | object | 否 | 见下表 | 全局 PM2 默认;可被应用 `pm2Config` 覆盖 |
|
|
@@ -282,11 +290,52 @@ await fastify.listen({ port: 3000 });
|
|
|
282
290
|
| 参数名 | 类型 | 必填 | 说明 |
|
|
283
291
|
|--------|------|------|------|
|
|
284
292
|
| name | string | 是 | 应用 slug |
|
|
285
|
-
| env | object | 是 | patch:`null` 删键;secret 键值为 `********`
|
|
293
|
+
| env | object | 是 | patch:`null` 删键;secret 键值为 `********` 时保持原值;`PORT` 与 `systemEnvKeys` 中的键忽略 |
|
|
286
294
|
| secretEnvKeys | array | 否 | 新增的显式密钥键名,与原列表合并写入 `options.secretEnvKeys`(仅保留仍存在于 env 的键)。密钥类型不可撤销,只有删除该变量才会移出列表 |
|
|
287
295
|
|
|
288
296
|
> **关键设计**:密钥判定 = 键名匹配 `secretEnvKeyPattern` **或** 出现在 `options.secretEnvKeys`。响应顶层 `secretEnvKeys` 为二者并集,便于管理端把普通变量标成密钥而不改键名。
|
|
289
297
|
|
|
298
|
+
##### GET `{prefix}/app/env/schema`
|
|
299
|
+
|
|
300
|
+
按版本的 `server/env.schema.json` 列出需要用户填写的环境变量,供环境变量管理页针对性补充。
|
|
301
|
+
|
|
302
|
+
| 参数名 | 类型 | 必填 | 说明 |
|
|
303
|
+
|--------|------|------|------|
|
|
304
|
+
| name | string | 是 | 应用 slug |
|
|
305
|
+
| versionId | string | 否 | 默认取当前版本,没有则取最新上传的版本 |
|
|
306
|
+
|
|
307
|
+
返回:
|
|
308
|
+
|
|
309
|
+
```json
|
|
310
|
+
{
|
|
311
|
+
"name": "demo",
|
|
312
|
+
"versionId": "354173577374729216",
|
|
313
|
+
"version": "1.0.0",
|
|
314
|
+
"hasSchema": true,
|
|
315
|
+
"valid": false,
|
|
316
|
+
"declared": [
|
|
317
|
+
{
|
|
318
|
+
"key": "API_TOKEN",
|
|
319
|
+
"type": "string",
|
|
320
|
+
"required": true,
|
|
321
|
+
"default": null,
|
|
322
|
+
"description": "接口令牌",
|
|
323
|
+
"enum": null,
|
|
324
|
+
"secret": true,
|
|
325
|
+
"configured": false,
|
|
326
|
+
"value": null,
|
|
327
|
+
"error": "必填,未配置"
|
|
328
|
+
}
|
|
329
|
+
],
|
|
330
|
+
"others": [{ "key": "EXTRA", "secret": false, "value": "x" }]
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
- `declared`:schema `properties` 中的变量;`secret` 来自 `writeOnly: true` 或密钥判定;`value` 为当前值(密钥掩码);`error` 为按当前配置校验的结果。
|
|
335
|
+
- `others`:已配置但 schema 未声明的变量。
|
|
336
|
+
- 两者都**不含应用中心注入的变量**(见 `services.app.listInjectedEnvKeys`):`PORT`、`DB_TABLE_PREFIX`、`systemEnvKeys`、`passthroughEnvKeys`、托管库连接 `DB_DIALECT` / `DB_HOST` / `DB_PORT` / `DB_DATABASE` / `DB_USERNAME` / `DB_PASSWORD` / `DB_STORAGE` 与 `DB_DEDICATED`(自有库在数据页签配置),以及最近一次启动时 `resolveSystemEnv` 返回的键(记录在 `options.injectedEnvKeys`)。注入的变量照常参与启动前校验。
|
|
337
|
+
- 版本没有 schema 时 `hasSchema=false`、`declared=[]`,该版本无法部署。
|
|
338
|
+
|
|
290
339
|
##### 数据运维与表生命周期
|
|
291
340
|
|
|
292
341
|
解析后的应用库:应用 `env` 的 `DB_*` 优先,否则注入 `defaultAppDb`。共享库(未自配 `DB_*`)仅能操作 `options.ownedTables`。
|
|
@@ -323,16 +372,17 @@ await fastify.listen({ port: 3000 });
|
|
|
323
372
|
|
|
324
373
|
##### POST `{prefix}/app/version/upload`
|
|
325
374
|
|
|
326
|
-
`multipart/form-data` 上传 zip
|
|
375
|
+
`multipart/form-data` 上传 zip;或 JSON 传 `fileId`,由 `resolveVersionFile` 读取宿主文件(如文件管理中已上传的 zip)。
|
|
327
376
|
|
|
328
377
|
| 字段名 | 类型 | 必填 | 说明 |
|
|
329
378
|
|--------|------|------|------|
|
|
330
|
-
| file | file |
|
|
379
|
+
| file | file | 否 | zip 二进制(字段名任意 file part);与 `fileId` 二选一,同时存在时以 `file` 为准 |
|
|
380
|
+
| fileId | string | 否 | 宿主文件 id,交给 `resolveVersionFile` 读取;与 `file` 二选一 |
|
|
331
381
|
| name | string | 是 | 应用 slug(须已 create) |
|
|
332
382
|
| version | string | 是 | 版本号;同应用内唯一 |
|
|
333
383
|
| label | string | 否 | 版本说明 |
|
|
334
384
|
|
|
335
|
-
成功返回 `appVersion` 记录(含 `artifactPath`、`hasMigration`
|
|
385
|
+
成功返回 `appVersion` 记录(含 `artifactPath`、`hasMigration`、`hasEnvSchema`、`fileId`、`sqlFiles` 等)。包中 `server/{sqlPath}/*.sql` 的内容与 `server/env.schema.json` 写入版本记录(数据库);缺少 `server/env.schema.json` 或其不符合规范的包直接拒绝(400)。传 `fileId` 时只解压校验、不装依赖,也不保留版本目录(部署时再从宿主文件解压);multipart 上传无处重建,解压并 `npm install` 后保留目录。包在临时目录处理完才写入版本表,上传中或失败的包不会出现在版本列表;包非法或 npm 失败时清理临时目录并返回 400。版本号已存在返回 409;同版本号仅残留软删除记录时会先彻底清除再写入。
|
|
336
386
|
|
|
337
387
|
##### GET `{prefix}/app/version/list`
|
|
338
388
|
|
|
@@ -361,9 +411,20 @@ await fastify.listen({ port: 3000 });
|
|
|
361
411
|
|
|
362
412
|
`hasMigration` 为 true 时额外附带 `sqlFiles` 列表。
|
|
363
413
|
|
|
414
|
+
##### GET `{prefix}/app/version/download`
|
|
415
|
+
|
|
416
|
+
下载版本部署包,文件名 `{name}-{version}.zip`。
|
|
417
|
+
|
|
418
|
+
| 参数名 | 类型 | 必填 | 说明 |
|
|
419
|
+
|--------|------|------|------|
|
|
420
|
+
| name | string | 是 | 应用 slug |
|
|
421
|
+
| versionId | string | 是 | 版本 id;不属于该应用返回 404 |
|
|
422
|
+
|
|
423
|
+
版本有 `fileId` 且配置了 `resolveVersionFile` 时返回原始 zip;否则(或读取失败时)由版本目录流式打包,排除部署时生成的 `server/node_modules` 与 `server/build`。版本目录也不存在时返回 404。
|
|
424
|
+
|
|
364
425
|
##### POST `{prefix}/app/deploy`
|
|
365
426
|
|
|
366
|
-
|
|
427
|
+
选择版本部署。版本目录不在磁盘时先经 `resolveVersionFile` 取回 zip 解压并 `npm install`(耗时,取不回返回 400 且不改应用状态),再以数据库中的 SQL 覆盖目录内 `.sql`;接着按版本的 `env.schema.json` 校验最终环境变量(含注入的变量),版本没有 schema 或变量不合规时返回 400,且不切换版本、不执行迁移、不启动进程;随后立即返回 `deploying`,后台做健康检查。进程启动后删除该应用其它有 `fileId` 的版本目录(只保留当前版本)。
|
|
367
428
|
|
|
368
429
|
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
|
369
430
|
|--------|------|------|--------|------|
|
|
@@ -372,6 +433,7 @@ await fastify.listen({ port: 3000 });
|
|
|
372
433
|
| version | string | 否 | - | 版本号 |
|
|
373
434
|
| runMigration | boolean | 否 | `true` | false 时向 app.env 写入 `RUN_SQL_ON_SYNC=false`;预跑 SQL 仍受 `migrateBeforeStart` 约束 |
|
|
374
435
|
| migrations | array | 否 | - | `[{ name, action }]` 逐个指定**待执行**脚本的处理方式;未列出的按 `execute`。仅在 `runMigration` 且 `migrateBeforeStart` 时生效 |
|
|
436
|
+
| progressId | string | 否 | 随机 UUID | 进度标识;前端先用它连 `GET .../app/deploy/stream` 再发起部署,可收到完整进度 |
|
|
375
437
|
|
|
376
438
|
`migrations[].action`:
|
|
377
439
|
|
|
@@ -388,15 +450,48 @@ await fastify.listen({ port: 3000 });
|
|
|
388
450
|
"name": "demo",
|
|
389
451
|
"status": "deploying",
|
|
390
452
|
"versionId": "354173577374729216",
|
|
453
|
+
"progressId": "c0a8...",
|
|
391
454
|
"port": 4001,
|
|
392
455
|
"pathUrl": "/app/demo/",
|
|
393
456
|
"domain": null
|
|
394
457
|
}
|
|
395
458
|
```
|
|
396
459
|
|
|
460
|
+
##### GET `{prefix}/app/deploy/stream`
|
|
461
|
+
|
|
462
|
+
SSE 部署进度。连接时先推送 `snapshot`(该应用最近一次部署中匹配 `progressId` 的已有事件,便于断线重连),之后每个阶段变化推送 `progress`。每个应用只在内存中保留最近一次部署的事件。
|
|
463
|
+
|
|
464
|
+
| 参数名 | 类型 | 必填 | 说明 |
|
|
465
|
+
|--------|------|------|------|
|
|
466
|
+
| name | string | 是 | 应用 slug |
|
|
467
|
+
| progressId | string | 否 | 只推送该次部署的事件;不传则推送该应用所有部署事件 |
|
|
468
|
+
|
|
469
|
+
阶段(`steps`,依次推进):
|
|
470
|
+
|
|
471
|
+
| step | 说明 | `detail`(`status=running` 时) |
|
|
472
|
+
|------|------|------|
|
|
473
|
+
| `package` | 准备版本目录 | `reuse` 目录已在磁盘 / `download` 取回 zip / `extract` 解压 / `install` 安装依赖 |
|
|
474
|
+
| `env` | 按 `env.schema.json` 校验环境变量;失败时 `message` 列出每个不合规的键与原因 | - |
|
|
475
|
+
| `migrate` | 预跑 SQL 迁移;未开启 `runMigration`、`migrateBeforeStart` 或版本无脚本时为 `skipped` | - |
|
|
476
|
+
| `start` | PM2 拉起进程 | - |
|
|
477
|
+
| `ready` | 健康检查;通过为 `done`,否则 `error`(`message` 为原因,如 `health check timeout`) | - |
|
|
478
|
+
|
|
479
|
+
`status`:`running` / `done` / `skipped` / `error`。任一阶段 `error` 即部署失败;`ready` 为 `done` / `error` 时部署结束,前端可断开连接。
|
|
480
|
+
|
|
481
|
+
```text
|
|
482
|
+
event: snapshot
|
|
483
|
+
data: {"steps":["package","env","migrate","start","ready"],"events":[]}
|
|
484
|
+
|
|
485
|
+
event: progress
|
|
486
|
+
data: {"progressId":"p-1","step":"package","status":"running","detail":"download","message":null,"at":"2026-10-10T10:00:00.000Z"}
|
|
487
|
+
|
|
488
|
+
event: progress
|
|
489
|
+
data: {"progressId":"p-1","step":"ready","status":"done","detail":null,"message":null,"at":"2026-10-10T10:00:05.000Z"}
|
|
490
|
+
```
|
|
491
|
+
|
|
397
492
|
##### 版本迁移脚本管理
|
|
398
493
|
|
|
399
|
-
|
|
494
|
+
管理某版本的 `.sql` 文件。脚本内容存于版本记录(数据库),版本目录不在磁盘时也可查看和编辑;目录在磁盘上(当前版本)时同步写入 `server/{migrationPath}`,部署时以数据库为准覆盖。执行状态读写应用库的 `_fs_sql_migrations`(与 `@kne/fastify-sequelize` 共用,按文件名记账;共享库中不同应用的同名脚本会互相影响)。文件名须匹配 `[A-Za-z0-9_-.]+\.sql`。
|
|
400
495
|
|
|
401
496
|
| 方法 | 路径 | 参数 | 说明 |
|
|
402
497
|
|------|------|------|------|
|
|
@@ -460,9 +555,9 @@ await fastify.listen({ port: 3000 });
|
|
|
460
555
|
|
|
461
556
|
| 动作 | 行为摘要 |
|
|
462
557
|
|------|----------|
|
|
463
|
-
| start | 需已有 `currentVersionId
|
|
558
|
+
| start | 需已有 `currentVersionId`;先按 `env.schema.json` 校验环境变量,不通过返回 400 且不改状态;拉起进程并后台健康检查 |
|
|
464
559
|
| stop | PM2 stop,状态 `stopped` |
|
|
465
|
-
| restart |
|
|
560
|
+
| restart | 先校验环境变量(不通过返回 400,正在运行的进程不受影响),再停止并重新 startProcess |
|
|
466
561
|
| remove | 删 PM2 进程、版本记录、落盘目录与 App 行 |
|
|
467
562
|
|
|
468
563
|
##### GET `{prefix}/app/logs`
|
|
@@ -627,12 +722,24 @@ data: {"name":"demo","ts":1791456608533,...}
|
|
|
627
722
|
| `saveEnv({ name, env, secretEnvKeys? })` | 同 save-env |
|
|
628
723
|
| `list({ filter, perPage, currentPage })` | 列表 |
|
|
629
724
|
| `detail({ name })` | 详情(脱敏) |
|
|
630
|
-
| `uploadVersion({ name, version, label, zipBuffer })` |
|
|
725
|
+
| `uploadVersion({ name, version, label, zipBuffer, fileId? })` | 上传版本;`fileId` 记入版本记录供后续重建 |
|
|
726
|
+
| `resolveVersionPackage({ name, versionId })` | 下载用:返回 `{ fileName, buffer }`(原始 zip)或 `{ fileName, artifactPath, files }`(版本目录内待打包的相对路径) |
|
|
727
|
+
| `getMigrationFiles(ver)` | 读取版本 SQL `[{ name, content, updatedAt }]`;旧版本记录为空时从版本目录回填 |
|
|
728
|
+
| `saveMigrationFiles(ver, files)` | 写入版本 SQL 并同步 `hasMigration`,版本目录在磁盘上时同步覆盖 `.sql` |
|
|
729
|
+
| `pruneVersionArtifacts(app)` | 删除该应用非当前、且有 `fileId` 的版本目录(删前回填 SQL),返回被删版本号 |
|
|
730
|
+
| `pruneAllVersionArtifacts()` | 对所有应用执行 `pruneVersionArtifacts` |
|
|
731
|
+
| `restoreMissingArtifacts()` | 当前版本目录(`artifactPath/server`)缺失时,按版本 `fileId` 经 `resolveVersionFile` 取回 zip 重建并恢复 `current` 链接(不跑 SQL 迁移);返回 `[{ name, version, action: 'restored' \| 'skipped' \| 'failed', reason? }]`,无 `fileId` 的记 `skipped` |
|
|
631
732
|
| `listVersions({ name, perPage, currentPage })` | 版本列表 |
|
|
632
|
-
| `deploy({ name, versionId, version, runMigration, migrations? })` |
|
|
733
|
+
| `deploy({ name, versionId, version, runMigration, migrations?, progressId? })` | 部署;版本目录缺失时先从宿主文件重建,启动后清理其它版本目录;立即 `deploying`,后台 `syncRealStatus`;各阶段进度写入 `getDeployProgress()` |
|
|
734
|
+
| `getDeployProgress()` | 部署进度中心:`emitter`(事件名 `deploy:{name}`)、`snapshot(name)` 返回最近一次部署 `{ progressId, events }` |
|
|
735
|
+
| `waitDeployProgress({ name, progressId, timeoutMs? })` | 等到该次部署出错或 `ready` 结束(默认超时 120000ms),返回 `{ progressId, ok, timedOut, events }`;供 MCP 等同步调用方使用 |
|
|
736
|
+
| `getEnvDeclaration({ name, versionId? })` | 同 `GET .../app/env/schema` |
|
|
737
|
+
| `listInjectedEnvKeys(app)` | 应用中心注入(或在其它页签维护)的变量名 `Set`,环境变量声明中不展示 |
|
|
633
738
|
| `start` / `stop` / `restart` | 生命周期;`start`/`restart` 立即 `deploying`,后台按 PM2+health 写回真实状态 |
|
|
634
739
|
| `syncStatus({ name, recoverIfMissing? })` | 同步单个应用真实状态并返回公开对象 |
|
|
635
|
-
| `syncAllStatuses({ recoverIfMissing? })` | 批量同步(父应用启动 reconcile
|
|
740
|
+
| `syncAllStatuses({ recoverIfMissing?, excludeIds? })` | 批量同步(父应用启动 reconcile 使用);`excludeIds` 中的应用跳过 |
|
|
741
|
+
| `listMissingArtifacts()` | 列出当前版本目录(`artifactPath/server`)缺失的应用:`[{ app, ver }]` |
|
|
742
|
+
| `restoreArtifact({ app, ver })` | 补回单个应用的当前版本目录,返回同 `restoreMissingArtifacts` 的单项 |
|
|
636
743
|
| `remove({ name, exportBeforeRemove?, cleanupData?, ... })` | 删除;默认可导出+清理所属表 |
|
|
637
744
|
| `logs({ name, stream, file?, perPage, currentPage, beforeLine? })` | 读当前日志或指定归档 |
|
|
638
745
|
| `logFiles({ name })` | 列出日志文件 |
|
|
@@ -672,11 +779,13 @@ data: {"name":"demo","ts":1791456608533,...}
|
|
|
672
779
|
|
|
673
780
|
| 方法签名 | 说明 |
|
|
674
781
|
|----------|------|
|
|
675
|
-
| `onReady()` |
|
|
782
|
+
| `onReady()` | `ensureSchema` 后连接 PM2、挂 log bus、启动日志切分与负载采样定时器,并 **setImmediate** 异步执行 `recoverOnStartup`,以及一次 `rotateLogs` 与一次 `sampleLoad`(不阻塞父应用 ready) |
|
|
783
|
+
| `ensureSchema()` | 宿主 `sync()` 不给已有表加列:为老库补齐后续新增的可空列(当前为 `appVersion.fileId`、`appVersion.migrationFiles`),可重复执行 |
|
|
784
|
+
| `recoverOnStartup()` | 两路并行:部署目录完好的应用立即 `reconcile`;目录缺失的应用逐个 `restoreArtifact`(需重装依赖,较慢),每补完一个即单独 `syncStatus` 并按需拉起;补不回且原为 `running`/`deploying` 的标记 `error`;两路结束后 `pruneAllVersionArtifacts`。返回 `{ reconciled, restored, pruned }` |
|
|
676
785
|
| `onClose()` | 停止切分与采样定时器并等待进行中的任务,断开 bus / PM2(由插件 `onClose` 调用) |
|
|
677
786
|
| `rotateLogs({ now? })` | 立即执行一轮切分 + 压缩 + 保留清理;返回本轮切分的 `[{ name, stream, fileName, reason }]`,`reason` 为 `size` / `daily` |
|
|
678
787
|
| `sampleLoad({ now? })` | 立即采样一轮:每个应用一条样本写入负载缓冲并推送 SSE,返回样本数组;PM2 未连接时返回 `[]` |
|
|
679
|
-
| `reconcile()` | 调用 `app.syncAllStatuses({ recoverIfMissing: true })`:按 PM2 真实状态写回 DB;`running`/`deploying` 且进程缺失时尝试拉起 |
|
|
788
|
+
| `reconcile({ excludeIds? })` | 调用 `app.syncAllStatuses({ recoverIfMissing: true, excludeIds })`:按 PM2 真实状态写回 DB;`running`/`deploying` 且进程缺失时尝试拉起 |
|
|
680
789
|
|
|
681
790
|
#### 数据模型
|
|
682
791
|
|
|
@@ -715,6 +824,9 @@ data: {"name":"demo","ts":1791456608533,...}
|
|
|
715
824
|
| artifactPath | string | 解压目录 |
|
|
716
825
|
| hasMigration | boolean | 是否存在 sql 文件 |
|
|
717
826
|
| migrationPath | string | 相对 server 的 sql 目录,默认 `sql` |
|
|
827
|
+
| fileId | string / null | 宿主文件 id(经 `fileId` 上传时写入);版本目录据此按需重建,非当前版本不常驻磁盘 |
|
|
828
|
+
| migrationFiles | json / null | SQL 迁移脚本 `[{ name, content, updatedAt }]`,接口不返回(列表只给 `sqlFiles` 文件名) |
|
|
829
|
+
| envSchema | json / null | `server/env.schema.json` 内容,接口不返回(列表只给 `hasEnvSchema`);老版本为空时从版本目录回填 |
|
|
718
830
|
|
|
719
831
|
唯一约束:`(app_name, version)`。
|
|
720
832
|
|
|
@@ -778,9 +890,31 @@ rename 为 {stream}-{起始时间}.log ──→ pm2 reloadLogs ──→ gzip
|
|
|
778
890
|
| 非 Buffer / 超 `maxZipSize` / 超 `maxZipEntries` | 抛错 |
|
|
779
891
|
| 条目含 `..` 或逃出目标目录 | Zip Slip,抛错 |
|
|
780
892
|
| 缺根/server package、server/index、build 入口 html | `invalid package` |
|
|
893
|
+
| 缺 `server/env.schema.json`、JSON 非法或不符合规范 | `invalid package`(从宿主文件还原老版本目录时不检查,由启动前校验提示) |
|
|
781
894
|
|
|
782
895
|
部署时会将根 `build/` 复制到 `server/build/`,再对 `server/build` 做 entryHtml 注入。
|
|
783
896
|
|
|
897
|
+
##### 环境变量 schema(server/env.schema.json)
|
|
898
|
+
|
|
899
|
+
标准 JSON Schema,子应用 `@fastify/env` 直接 `require` 使用;为兼容 ajv 严格模式只用标准关键字:
|
|
900
|
+
|
|
901
|
+
- 顶层 `"type": "object"`,必填项写进顶层 `required`(其中的键必须在 `properties` 中声明)。
|
|
902
|
+
- `properties.<KEY>` 内用 `type` / `default` / `enum` / `pattern` / `description` / `title`;敏感值标 `"writeOnly": true`(环境变量管理按密钥处理)。不使用 `x-` 等自定义关键字。
|
|
903
|
+
- 校验规则与 `@fastify/env` 一致(`coerceTypes`、`useDefaults`);空字符串视为未配置。
|
|
904
|
+
|
|
905
|
+
启动前(deploy 的 `env` 阶段、start、restart、宿主重启后的恢复)都会校验,不通过时不启动进程。
|
|
906
|
+
|
|
907
|
+
```json
|
|
908
|
+
{
|
|
909
|
+
"type": "object",
|
|
910
|
+
"required": ["API_TOKEN"],
|
|
911
|
+
"properties": {
|
|
912
|
+
"API_TOKEN": { "type": "string", "writeOnly": true, "description": "接口令牌" },
|
|
913
|
+
"PORT": { "type": "number", "default": 8040, "description": "服务端口(应用中心注入)" }
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
```
|
|
917
|
+
|
|
784
918
|
##### 环境合并顺序
|
|
785
919
|
|
|
786
920
|
```
|
package/index.js
CHANGED
|
@@ -42,6 +42,8 @@ module.exports = fp(
|
|
|
42
42
|
resolveSystemEnv: null,
|
|
43
43
|
/** async ({ app }) => void,应用删除成功后调用,app 为删除前快照;报错只记日志 */
|
|
44
44
|
onAppRemoved: null,
|
|
45
|
+
/** async ({ fileId, name, version, request }) => Buffer,版本上传传 fileId 时读取宿主文件 */
|
|
46
|
+
resolveVersionFile: null,
|
|
45
47
|
secretEnvKeyPattern: /(SECRET|PASSWORD|TOKEN|KEY|PRIVATE)/i,
|
|
46
48
|
sqlPath: 'sql',
|
|
47
49
|
migrateBeforeStart: false,
|
package/libs/controllers/app.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
const fp = require('fastify-plugin');
|
|
2
|
+
const path = require('node:path');
|
|
2
3
|
const fs = require('fs-extra');
|
|
3
4
|
const yazl = require('yazl');
|
|
4
5
|
const { createSseReply } = require('../utils/logStream');
|
|
6
|
+
const { DEPLOY_STEPS } = require('../utils/deployProgress');
|
|
5
7
|
const { formatInTz } = require('../utils/logTime');
|
|
6
8
|
|
|
7
9
|
module.exports = fp(async (fastify, options) => {
|
|
@@ -112,34 +114,60 @@ module.exports = fp(async (fastify, options) => {
|
|
|
112
114
|
async request => services.app.saveEnv(request.body)
|
|
113
115
|
);
|
|
114
116
|
|
|
117
|
+
fastify.get(
|
|
118
|
+
`${options.prefix}/app/env/schema`,
|
|
119
|
+
{
|
|
120
|
+
onRequest: auth(),
|
|
121
|
+
schema: {
|
|
122
|
+
summary: '环境变量声明:按版本 env.schema.json 列出需要填写的变量(不含应用中心注入的变量)',
|
|
123
|
+
query: {
|
|
124
|
+
type: 'object',
|
|
125
|
+
required: ['name'],
|
|
126
|
+
properties: {
|
|
127
|
+
name: { type: 'string' },
|
|
128
|
+
versionId: { type: 'string', description: '默认取当前版本,没有则取最新上传的版本' }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
async request => services.app.getEnvDeclaration(request.query)
|
|
134
|
+
);
|
|
135
|
+
|
|
115
136
|
fastify.post(
|
|
116
137
|
`${options.prefix}/app/version/upload`,
|
|
117
138
|
{
|
|
118
139
|
onRequest: auth(),
|
|
119
140
|
schema: {
|
|
120
|
-
summary: '上传应用版本 zip'
|
|
141
|
+
summary: '上传应用版本 zip(multipart 传 file,或 JSON 传宿主文件 fileId)'
|
|
121
142
|
}
|
|
122
143
|
},
|
|
123
144
|
async request => {
|
|
124
|
-
|
|
125
|
-
let name;
|
|
126
|
-
let version;
|
|
127
|
-
let label;
|
|
145
|
+
let { name, version, label, fileId } = request.isMultipart() ? {} : request.body || {};
|
|
128
146
|
let zipBuffer;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
147
|
+
if (request.isMultipart()) {
|
|
148
|
+
for await (const part of request.parts()) {
|
|
149
|
+
if (part.type === 'file') {
|
|
150
|
+
zipBuffer = await part.toBuffer();
|
|
151
|
+
} else {
|
|
152
|
+
const val = part.value;
|
|
153
|
+
if (part.fieldname === 'name') name = val;
|
|
154
|
+
if (part.fieldname === 'version') version = val;
|
|
155
|
+
if (part.fieldname === 'label') label = val;
|
|
156
|
+
if (part.fieldname === 'fileId') fileId = val;
|
|
157
|
+
}
|
|
137
158
|
}
|
|
138
159
|
}
|
|
139
|
-
if (
|
|
140
|
-
|
|
160
|
+
if (zipBuffer) {
|
|
161
|
+
return services.app.uploadVersion({ name, version, label, zipBuffer });
|
|
141
162
|
}
|
|
142
|
-
|
|
163
|
+
if (!fileId) {
|
|
164
|
+
throw fastify.httpErrors.badRequest('file or fileId is required');
|
|
165
|
+
}
|
|
166
|
+
if (typeof options.resolveVersionFile !== 'function') {
|
|
167
|
+
throw fastify.httpErrors.badRequest('fileId upload is not supported: resolveVersionFile is not configured');
|
|
168
|
+
}
|
|
169
|
+
zipBuffer = await options.resolveVersionFile({ fileId, name, version, request });
|
|
170
|
+
return services.app.uploadVersion({ name, version, label, zipBuffer, fileId });
|
|
143
171
|
}
|
|
144
172
|
);
|
|
145
173
|
|
|
@@ -163,6 +191,39 @@ module.exports = fp(async (fastify, options) => {
|
|
|
163
191
|
async request => services.app.listVersions(request.query)
|
|
164
192
|
);
|
|
165
193
|
|
|
194
|
+
fastify.get(
|
|
195
|
+
`${options.prefix}/app/version/download`,
|
|
196
|
+
{
|
|
197
|
+
onRequest: auth(),
|
|
198
|
+
schema: {
|
|
199
|
+
summary: '下载版本部署包(有 fileId 返回原始 zip,否则由版本目录打包)',
|
|
200
|
+
query: {
|
|
201
|
+
type: 'object',
|
|
202
|
+
required: ['name', 'versionId'],
|
|
203
|
+
properties: {
|
|
204
|
+
name: { type: 'string' },
|
|
205
|
+
versionId: { type: 'string' }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
async (request, reply) => {
|
|
211
|
+
const { fileName, buffer, artifactPath, files } = await services.app.resolveVersionPackage(request.query);
|
|
212
|
+
reply.header('Content-Type', 'application/zip');
|
|
213
|
+
reply.header('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"; filename*=UTF-8''${encodeURIComponent(fileName)}`);
|
|
214
|
+
if (buffer) {
|
|
215
|
+
reply.header('Content-Length', buffer.length);
|
|
216
|
+
return reply.send(buffer);
|
|
217
|
+
}
|
|
218
|
+
const zipFile = new yazl.ZipFile();
|
|
219
|
+
for (const file of files) {
|
|
220
|
+
zipFile.addFile(path.join(artifactPath, file), file);
|
|
221
|
+
}
|
|
222
|
+
zipFile.end();
|
|
223
|
+
return reply.send(zipFile.outputStream);
|
|
224
|
+
}
|
|
225
|
+
);
|
|
226
|
+
|
|
166
227
|
fastify.post(
|
|
167
228
|
`${options.prefix}/app/deploy`,
|
|
168
229
|
{
|
|
@@ -177,6 +238,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
177
238
|
versionId: { type: 'string' },
|
|
178
239
|
version: { type: 'string' },
|
|
179
240
|
runMigration: { type: 'boolean', default: true },
|
|
241
|
+
progressId: { type: 'string', description: '进度标识;先用它订阅 deploy/stream 再发起部署' },
|
|
180
242
|
migrations: {
|
|
181
243
|
type: 'array',
|
|
182
244
|
description: '逐个指定待执行脚本的处理方式;未列出的待执行脚本按 execute 处理',
|
|
@@ -196,6 +258,44 @@ module.exports = fp(async (fastify, options) => {
|
|
|
196
258
|
async request => services.app.deploy(request.body)
|
|
197
259
|
);
|
|
198
260
|
|
|
261
|
+
fastify.get(
|
|
262
|
+
`${options.prefix}/app/deploy/stream`,
|
|
263
|
+
{
|
|
264
|
+
onRequest: auth(),
|
|
265
|
+
schema: {
|
|
266
|
+
summary: 'SSE 部署进度',
|
|
267
|
+
query: {
|
|
268
|
+
type: 'object',
|
|
269
|
+
required: ['name'],
|
|
270
|
+
properties: {
|
|
271
|
+
name: { type: 'string' },
|
|
272
|
+
progressId: { type: 'string' }
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
async (request, reply) => {
|
|
278
|
+
const { name, progressId } = request.query;
|
|
279
|
+
await services.app.detail({ name });
|
|
280
|
+
const sse = createSseReply(reply, { heartbeatMs: options.sseHeartbeatMs });
|
|
281
|
+
const progress = services.app.getDeployProgress();
|
|
282
|
+
const matches = event => !progressId || event.progressId === progressId;
|
|
283
|
+
const snapshot = progress.snapshot(name);
|
|
284
|
+
sse.writeEvent('snapshot', {
|
|
285
|
+
steps: DEPLOY_STEPS,
|
|
286
|
+
events: snapshot ? snapshot.events.filter(matches) : []
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const onProgress = event => matches(event) && sse.writeEvent('progress', event);
|
|
290
|
+
const eventName = `deploy:${name}`;
|
|
291
|
+
progress.emitter.on(eventName, onProgress);
|
|
292
|
+
request.raw.on('close', () => {
|
|
293
|
+
progress.emitter.off(eventName, onProgress);
|
|
294
|
+
sse.close();
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
);
|
|
298
|
+
|
|
199
299
|
fastify.get(
|
|
200
300
|
`${options.prefix}/app/list`,
|
|
201
301
|
{
|
|
@@ -33,6 +33,21 @@ module.exports = ({ DataTypes }) => {
|
|
|
33
33
|
allowNull: true,
|
|
34
34
|
defaultValue: 'sql',
|
|
35
35
|
comment: '相对 server 的 sql 目录'
|
|
36
|
+
},
|
|
37
|
+
fileId: {
|
|
38
|
+
type: DataTypes.STRING,
|
|
39
|
+
allowNull: true,
|
|
40
|
+
comment: '宿主文件 id;版本目录缺失时经 resolveVersionFile 重建'
|
|
41
|
+
},
|
|
42
|
+
migrationFiles: {
|
|
43
|
+
type: DataTypes.JSON,
|
|
44
|
+
allowNull: true,
|
|
45
|
+
comment: 'SQL 迁移文件 [{ name, content, updatedAt }];版本目录可随时删除,部署时写回磁盘'
|
|
46
|
+
},
|
|
47
|
+
envSchema: {
|
|
48
|
+
type: DataTypes.JSON,
|
|
49
|
+
allowNull: true,
|
|
50
|
+
comment: 'server/env.schema.json 内容;启动前按它校验环境变量'
|
|
36
51
|
}
|
|
37
52
|
},
|
|
38
53
|
options: {
|