@kne/fastify-app-manager 0.1.10 → 0.1.11
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 +77 -15
- package/index.js +2 -0
- package/libs/controllers/app.js +97 -16
- package/libs/models/appVersion.js +10 -0
- package/libs/services/app.js +262 -27
- package/libs/services/bootstrap.js +68 -5
- package/libs/services/migration.js +34 -46
- package/libs/utils/deployProgress.js +37 -0
- package/libs/utils/version.js +7 -2
- package/libs/utils/versionSql.js +33 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,18 +51,20 @@ 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
|
+
status=deploying → 可选 SQL 迁移 → inject entryHtml → pm2.start → 清理其它版本目录
|
|
66
68
|
↓(立即返回)
|
|
67
69
|
后台 healthCheck → running / error
|
|
68
70
|
```
|
|
@@ -74,7 +76,7 @@ deploy(versionId)
|
|
|
74
76
|
| 概念 | 说明 |
|
|
75
77
|
|------|------|
|
|
76
78
|
| **App** | 元信息、分配端口、env、pm2Config、状态机;落盘目录 `appsRoot/{name}` |
|
|
77
|
-
| **AppVersion** | 一次 zip 上传产物;`artifactPath`
|
|
79
|
+
| **AppVersion** | 一次 zip 上传产物;`artifactPath` 为解压目录(有 `fileId` 的版本只在当前部署时常驻磁盘),SQL 脚本存于 `migrationFiles`;部署时按 id 或 version 选择 |
|
|
78
80
|
| **端口池** | 默认 `4000–7999`,创建时分配未占用端口;`PORT` 强制写入子进程 env |
|
|
79
81
|
|
|
80
82
|
##### 状态机
|
|
@@ -120,6 +122,7 @@ deploy(versionId)
|
|
|
120
122
|
|------|------|
|
|
121
123
|
| 版本上传 | Zip Slip 防护、条目数/体积上限、解压丢弃任意层级 `node_modules`、Fullstack 包格式校验、server 生产依赖安装 |
|
|
122
124
|
| 短部署 | `deploy` / `start` / `restart` 立即返回 `deploying`,后台按 PM2 真实状态 + healthCheck 写回 `running`/`error`/`stopped` |
|
|
125
|
+
| 部署进度 | `deploy` 按「准备部署包 → SQL 迁移 → 启动进程 → 等待就绪」上报阶段,经 `GET .../app/deploy/stream` SSE 推送(含下载 / 解压 / 安装依赖等子阶段) |
|
|
123
126
|
| 生命周期 | `start` / `stop` / `restart` / `remove`;父应用重启后异步 `syncAllStatuses` 对齐 PM2 |
|
|
124
127
|
| 双通道访问 | 自定义域名透明反代 + `/app/{name}` 剥前缀反代 |
|
|
125
128
|
| 日志 | PM2 落盘 + 历史分页(`beforeLine` 上滚、每页最多 100 行)+ SSE(含回放行与心跳) |
|
|
@@ -194,6 +197,7 @@ await fastify.listen({ port: 3000 });
|
|
|
194
197
|
| `systemEnvKeys` | array | 否 | `[]` | 宿主维护的系统变量键名,与内置 `DB_TABLE_PREFIX` 合并:接口不返回,create / save / saveEnv 不可新增、修改或删除 |
|
|
195
198
|
| `resolveSystemEnv` | function | 否 | `null` | `async ({ app, version, serverDir }) => env \| null`,每次启动进程(deploy / start / restart / 恢复)前调用;返回值覆盖应用变量注入子进程,值为 `null` / `undefined` 的键忽略,属于 `systemEnvKeys` 的键持久化到 `app.env` |
|
|
196
199
|
| `onAppRemoved` | function | 否 | `null` | `async ({ app }) => void`,应用删除成功后调用,`app` 为删除前快照(含 `env`);数据清理未完成而未删除时不调用,钩子报错只记 warn 日志 |
|
|
200
|
+
| `resolveVersionFile` | function | 否 | `null` | `async ({ fileId, name, version, request }) => Buffer`,版本上传传 `fileId`(而非 multipart 文件)时调用,返回 zip 内容;未配置时传 `fileId` 返回 400。版本记录保存 `fileId`;这类版本只在当前部署时常驻磁盘,部署非当前版本或启动时当前版本目录缺失会再次调用以重建 |
|
|
197
201
|
| `defaultAppDb` | object | 否 | `appsRoot/_shared/apps-data.sqlite` | 托管应用默认库(须与宿主主库分离) |
|
|
198
202
|
| `dbQueryMaxRows` | number | 否 | `500` | 只读 query SQL 最大返回行数 |
|
|
199
203
|
| `pm2Defaults` | object | 否 | 见下表 | 全局 PM2 默认;可被应用 `pm2Config` 覆盖 |
|
|
@@ -323,16 +327,17 @@ await fastify.listen({ port: 3000 });
|
|
|
323
327
|
|
|
324
328
|
##### POST `{prefix}/app/version/upload`
|
|
325
329
|
|
|
326
|
-
`multipart/form-data` 上传 zip
|
|
330
|
+
`multipart/form-data` 上传 zip;或 JSON 传 `fileId`,由 `resolveVersionFile` 读取宿主文件(如文件管理中已上传的 zip)。
|
|
327
331
|
|
|
328
332
|
| 字段名 | 类型 | 必填 | 说明 |
|
|
329
333
|
|--------|------|------|------|
|
|
330
|
-
| file | file |
|
|
334
|
+
| file | file | 否 | zip 二进制(字段名任意 file part);与 `fileId` 二选一,同时存在时以 `file` 为准 |
|
|
335
|
+
| fileId | string | 否 | 宿主文件 id,交给 `resolveVersionFile` 读取;与 `file` 二选一 |
|
|
331
336
|
| name | string | 是 | 应用 slug(须已 create) |
|
|
332
337
|
| version | string | 是 | 版本号;同应用内唯一 |
|
|
333
338
|
| label | string | 否 | 版本说明 |
|
|
334
339
|
|
|
335
|
-
成功返回 `appVersion` 记录(含 `artifactPath`、`hasMigration`
|
|
340
|
+
成功返回 `appVersion` 记录(含 `artifactPath`、`hasMigration`、`fileId`、`sqlFiles` 等)。包中 `server/{sqlPath}/*.sql` 的内容写入版本记录(数据库)。传 `fileId` 时只解压校验、不装依赖,也不保留版本目录(部署时再从宿主文件解压);multipart 上传无处重建,解压并 `npm install` 后保留目录。包在临时目录处理完才写入版本表,上传中或失败的包不会出现在版本列表;包非法或 npm 失败时清理临时目录并返回 400。版本号已存在返回 409;同版本号仅残留软删除记录时会先彻底清除再写入。
|
|
336
341
|
|
|
337
342
|
##### GET `{prefix}/app/version/list`
|
|
338
343
|
|
|
@@ -361,9 +366,20 @@ await fastify.listen({ port: 3000 });
|
|
|
361
366
|
|
|
362
367
|
`hasMigration` 为 true 时额外附带 `sqlFiles` 列表。
|
|
363
368
|
|
|
369
|
+
##### GET `{prefix}/app/version/download`
|
|
370
|
+
|
|
371
|
+
下载版本部署包,文件名 `{name}-{version}.zip`。
|
|
372
|
+
|
|
373
|
+
| 参数名 | 类型 | 必填 | 说明 |
|
|
374
|
+
|--------|------|------|------|
|
|
375
|
+
| name | string | 是 | 应用 slug |
|
|
376
|
+
| versionId | string | 是 | 版本 id;不属于该应用返回 404 |
|
|
377
|
+
|
|
378
|
+
版本有 `fileId` 且配置了 `resolveVersionFile` 时返回原始 zip;否则(或读取失败时)由版本目录流式打包,排除部署时生成的 `server/node_modules` 与 `server/build`。版本目录也不存在时返回 404。
|
|
379
|
+
|
|
364
380
|
##### POST `{prefix}/app/deploy`
|
|
365
381
|
|
|
366
|
-
|
|
382
|
+
选择版本部署。版本目录不在磁盘时先经 `resolveVersionFile` 取回 zip 解压并 `npm install`(耗时,取不回返回 400 且不改应用状态),再以数据库中的 SQL 覆盖目录内 `.sql`;随后立即返回 `deploying`,后台做健康检查。进程启动后删除该应用其它有 `fileId` 的版本目录(只保留当前版本)。
|
|
367
383
|
|
|
368
384
|
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
|
369
385
|
|--------|------|------|--------|------|
|
|
@@ -372,6 +388,7 @@ await fastify.listen({ port: 3000 });
|
|
|
372
388
|
| version | string | 否 | - | 版本号 |
|
|
373
389
|
| runMigration | boolean | 否 | `true` | false 时向 app.env 写入 `RUN_SQL_ON_SYNC=false`;预跑 SQL 仍受 `migrateBeforeStart` 约束 |
|
|
374
390
|
| migrations | array | 否 | - | `[{ name, action }]` 逐个指定**待执行**脚本的处理方式;未列出的按 `execute`。仅在 `runMigration` 且 `migrateBeforeStart` 时生效 |
|
|
391
|
+
| progressId | string | 否 | 随机 UUID | 进度标识;前端先用它连 `GET .../app/deploy/stream` 再发起部署,可收到完整进度 |
|
|
375
392
|
|
|
376
393
|
`migrations[].action`:
|
|
377
394
|
|
|
@@ -388,15 +405,47 @@ await fastify.listen({ port: 3000 });
|
|
|
388
405
|
"name": "demo",
|
|
389
406
|
"status": "deploying",
|
|
390
407
|
"versionId": "354173577374729216",
|
|
408
|
+
"progressId": "c0a8...",
|
|
391
409
|
"port": 4001,
|
|
392
410
|
"pathUrl": "/app/demo/",
|
|
393
411
|
"domain": null
|
|
394
412
|
}
|
|
395
413
|
```
|
|
396
414
|
|
|
415
|
+
##### GET `{prefix}/app/deploy/stream`
|
|
416
|
+
|
|
417
|
+
SSE 部署进度。连接时先推送 `snapshot`(该应用最近一次部署中匹配 `progressId` 的已有事件,便于断线重连),之后每个阶段变化推送 `progress`。每个应用只在内存中保留最近一次部署的事件。
|
|
418
|
+
|
|
419
|
+
| 参数名 | 类型 | 必填 | 说明 |
|
|
420
|
+
|--------|------|------|------|
|
|
421
|
+
| name | string | 是 | 应用 slug |
|
|
422
|
+
| progressId | string | 否 | 只推送该次部署的事件;不传则推送该应用所有部署事件 |
|
|
423
|
+
|
|
424
|
+
阶段(`steps`,依次推进):
|
|
425
|
+
|
|
426
|
+
| step | 说明 | `detail`(`status=running` 时) |
|
|
427
|
+
|------|------|------|
|
|
428
|
+
| `package` | 准备版本目录 | `reuse` 目录已在磁盘 / `download` 取回 zip / `extract` 解压 / `install` 安装依赖 |
|
|
429
|
+
| `migrate` | 预跑 SQL 迁移;未开启 `runMigration`、`migrateBeforeStart` 或版本无脚本时为 `skipped` | - |
|
|
430
|
+
| `start` | PM2 拉起进程 | - |
|
|
431
|
+
| `ready` | 健康检查;通过为 `done`,否则 `error`(`message` 为原因,如 `health check timeout`) | - |
|
|
432
|
+
|
|
433
|
+
`status`:`running` / `done` / `skipped` / `error`。任一阶段 `error` 即部署失败;`ready` 为 `done` / `error` 时部署结束,前端可断开连接。
|
|
434
|
+
|
|
435
|
+
```text
|
|
436
|
+
event: snapshot
|
|
437
|
+
data: {"steps":["package","migrate","start","ready"],"events":[]}
|
|
438
|
+
|
|
439
|
+
event: progress
|
|
440
|
+
data: {"progressId":"p-1","step":"package","status":"running","detail":"download","message":null,"at":"2026-10-10T10:00:00.000Z"}
|
|
441
|
+
|
|
442
|
+
event: progress
|
|
443
|
+
data: {"progressId":"p-1","step":"ready","status":"done","detail":null,"message":null,"at":"2026-10-10T10:00:05.000Z"}
|
|
444
|
+
```
|
|
445
|
+
|
|
397
446
|
##### 版本迁移脚本管理
|
|
398
447
|
|
|
399
|
-
|
|
448
|
+
管理某版本的 `.sql` 文件。脚本内容存于版本记录(数据库),版本目录不在磁盘时也可查看和编辑;目录在磁盘上(当前版本)时同步写入 `server/{migrationPath}`,部署时以数据库为准覆盖。执行状态读写应用库的 `_fs_sql_migrations`(与 `@kne/fastify-sequelize` 共用,按文件名记账;共享库中不同应用的同名脚本会互相影响)。文件名须匹配 `[A-Za-z0-9_-.]+\.sql`。
|
|
400
449
|
|
|
401
450
|
| 方法 | 路径 | 参数 | 说明 |
|
|
402
451
|
|------|------|------|------|
|
|
@@ -627,12 +676,21 @@ data: {"name":"demo","ts":1791456608533,...}
|
|
|
627
676
|
| `saveEnv({ name, env, secretEnvKeys? })` | 同 save-env |
|
|
628
677
|
| `list({ filter, perPage, currentPage })` | 列表 |
|
|
629
678
|
| `detail({ name })` | 详情(脱敏) |
|
|
630
|
-
| `uploadVersion({ name, version, label, zipBuffer })` |
|
|
679
|
+
| `uploadVersion({ name, version, label, zipBuffer, fileId? })` | 上传版本;`fileId` 记入版本记录供后续重建 |
|
|
680
|
+
| `resolveVersionPackage({ name, versionId })` | 下载用:返回 `{ fileName, buffer }`(原始 zip)或 `{ fileName, artifactPath, files }`(版本目录内待打包的相对路径) |
|
|
681
|
+
| `getMigrationFiles(ver)` | 读取版本 SQL `[{ name, content, updatedAt }]`;旧版本记录为空时从版本目录回填 |
|
|
682
|
+
| `saveMigrationFiles(ver, files)` | 写入版本 SQL 并同步 `hasMigration`,版本目录在磁盘上时同步覆盖 `.sql` |
|
|
683
|
+
| `pruneVersionArtifacts(app)` | 删除该应用非当前、且有 `fileId` 的版本目录(删前回填 SQL),返回被删版本号 |
|
|
684
|
+
| `pruneAllVersionArtifacts()` | 对所有应用执行 `pruneVersionArtifacts` |
|
|
685
|
+
| `restoreMissingArtifacts()` | 当前版本目录(`artifactPath/server`)缺失时,按版本 `fileId` 经 `resolveVersionFile` 取回 zip 重建并恢复 `current` 链接(不跑 SQL 迁移);返回 `[{ name, version, action: 'restored' \| 'skipped' \| 'failed', reason? }]`,无 `fileId` 的记 `skipped` |
|
|
631
686
|
| `listVersions({ name, perPage, currentPage })` | 版本列表 |
|
|
632
|
-
| `deploy({ name, versionId, version, runMigration, migrations? })` |
|
|
687
|
+
| `deploy({ name, versionId, version, runMigration, migrations?, progressId? })` | 部署;版本目录缺失时先从宿主文件重建,启动后清理其它版本目录;立即 `deploying`,后台 `syncRealStatus`;各阶段进度写入 `getDeployProgress()` |
|
|
688
|
+
| `getDeployProgress()` | 部署进度中心:`emitter`(事件名 `deploy:{name}`)、`snapshot(name)` 返回最近一次部署 `{ progressId, events }` |
|
|
633
689
|
| `start` / `stop` / `restart` | 生命周期;`start`/`restart` 立即 `deploying`,后台按 PM2+health 写回真实状态 |
|
|
634
690
|
| `syncStatus({ name, recoverIfMissing? })` | 同步单个应用真实状态并返回公开对象 |
|
|
635
|
-
| `syncAllStatuses({ recoverIfMissing? })` | 批量同步(父应用启动 reconcile
|
|
691
|
+
| `syncAllStatuses({ recoverIfMissing?, excludeIds? })` | 批量同步(父应用启动 reconcile 使用);`excludeIds` 中的应用跳过 |
|
|
692
|
+
| `listMissingArtifacts()` | 列出当前版本目录(`artifactPath/server`)缺失的应用:`[{ app, ver }]` |
|
|
693
|
+
| `restoreArtifact({ app, ver })` | 补回单个应用的当前版本目录,返回同 `restoreMissingArtifacts` 的单项 |
|
|
636
694
|
| `remove({ name, exportBeforeRemove?, cleanupData?, ... })` | 删除;默认可导出+清理所属表 |
|
|
637
695
|
| `logs({ name, stream, file?, perPage, currentPage, beforeLine? })` | 读当前日志或指定归档 |
|
|
638
696
|
| `logFiles({ name })` | 列出日志文件 |
|
|
@@ -672,11 +730,13 @@ data: {"name":"demo","ts":1791456608533,...}
|
|
|
672
730
|
|
|
673
731
|
| 方法签名 | 说明 |
|
|
674
732
|
|----------|------|
|
|
675
|
-
| `onReady()` |
|
|
733
|
+
| `onReady()` | `ensureSchema` 后连接 PM2、挂 log bus、启动日志切分与负载采样定时器,并 **setImmediate** 异步执行 `recoverOnStartup`,以及一次 `rotateLogs` 与一次 `sampleLoad`(不阻塞父应用 ready) |
|
|
734
|
+
| `ensureSchema()` | 宿主 `sync()` 不给已有表加列:为老库补齐后续新增的可空列(当前为 `appVersion.fileId`、`appVersion.migrationFiles`),可重复执行 |
|
|
735
|
+
| `recoverOnStartup()` | 两路并行:部署目录完好的应用立即 `reconcile`;目录缺失的应用逐个 `restoreArtifact`(需重装依赖,较慢),每补完一个即单独 `syncStatus` 并按需拉起;补不回且原为 `running`/`deploying` 的标记 `error`;两路结束后 `pruneAllVersionArtifacts`。返回 `{ reconciled, restored, pruned }` |
|
|
676
736
|
| `onClose()` | 停止切分与采样定时器并等待进行中的任务,断开 bus / PM2(由插件 `onClose` 调用) |
|
|
677
737
|
| `rotateLogs({ now? })` | 立即执行一轮切分 + 压缩 + 保留清理;返回本轮切分的 `[{ name, stream, fileName, reason }]`,`reason` 为 `size` / `daily` |
|
|
678
738
|
| `sampleLoad({ now? })` | 立即采样一轮:每个应用一条样本写入负载缓冲并推送 SSE,返回样本数组;PM2 未连接时返回 `[]` |
|
|
679
|
-
| `reconcile()` | 调用 `app.syncAllStatuses({ recoverIfMissing: true })`:按 PM2 真实状态写回 DB;`running`/`deploying` 且进程缺失时尝试拉起 |
|
|
739
|
+
| `reconcile({ excludeIds? })` | 调用 `app.syncAllStatuses({ recoverIfMissing: true, excludeIds })`:按 PM2 真实状态写回 DB;`running`/`deploying` 且进程缺失时尝试拉起 |
|
|
680
740
|
|
|
681
741
|
#### 数据模型
|
|
682
742
|
|
|
@@ -715,6 +775,8 @@ data: {"name":"demo","ts":1791456608533,...}
|
|
|
715
775
|
| artifactPath | string | 解压目录 |
|
|
716
776
|
| hasMigration | boolean | 是否存在 sql 文件 |
|
|
717
777
|
| migrationPath | string | 相对 server 的 sql 目录,默认 `sql` |
|
|
778
|
+
| fileId | string / null | 宿主文件 id(经 `fileId` 上传时写入);版本目录据此按需重建,非当前版本不常驻磁盘 |
|
|
779
|
+
| migrationFiles | json / null | SQL 迁移脚本 `[{ name, content, updatedAt }]`,接口不返回(列表只给 `sqlFiles` 文件名) |
|
|
718
780
|
|
|
719
781
|
唯一约束:`(app_name, version)`。
|
|
720
782
|
|
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) => {
|
|
@@ -117,29 +119,36 @@ module.exports = fp(async (fastify, options) => {
|
|
|
117
119
|
{
|
|
118
120
|
onRequest: auth(),
|
|
119
121
|
schema: {
|
|
120
|
-
summary: '上传应用版本 zip'
|
|
122
|
+
summary: '上传应用版本 zip(multipart 传 file,或 JSON 传宿主文件 fileId)'
|
|
121
123
|
}
|
|
122
124
|
},
|
|
123
125
|
async request => {
|
|
124
|
-
|
|
125
|
-
let name;
|
|
126
|
-
let version;
|
|
127
|
-
let label;
|
|
126
|
+
let { name, version, label, fileId } = request.isMultipart() ? {} : request.body || {};
|
|
128
127
|
let zipBuffer;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
128
|
+
if (request.isMultipart()) {
|
|
129
|
+
for await (const part of request.parts()) {
|
|
130
|
+
if (part.type === 'file') {
|
|
131
|
+
zipBuffer = await part.toBuffer();
|
|
132
|
+
} else {
|
|
133
|
+
const val = part.value;
|
|
134
|
+
if (part.fieldname === 'name') name = val;
|
|
135
|
+
if (part.fieldname === 'version') version = val;
|
|
136
|
+
if (part.fieldname === 'label') label = val;
|
|
137
|
+
if (part.fieldname === 'fileId') fileId = val;
|
|
138
|
+
}
|
|
137
139
|
}
|
|
138
140
|
}
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
+
if (zipBuffer) {
|
|
142
|
+
return services.app.uploadVersion({ name, version, label, zipBuffer });
|
|
143
|
+
}
|
|
144
|
+
if (!fileId) {
|
|
145
|
+
throw fastify.httpErrors.badRequest('file or fileId is required');
|
|
141
146
|
}
|
|
142
|
-
|
|
147
|
+
if (typeof options.resolveVersionFile !== 'function') {
|
|
148
|
+
throw fastify.httpErrors.badRequest('fileId upload is not supported: resolveVersionFile is not configured');
|
|
149
|
+
}
|
|
150
|
+
zipBuffer = await options.resolveVersionFile({ fileId, name, version, request });
|
|
151
|
+
return services.app.uploadVersion({ name, version, label, zipBuffer, fileId });
|
|
143
152
|
}
|
|
144
153
|
);
|
|
145
154
|
|
|
@@ -163,6 +172,39 @@ module.exports = fp(async (fastify, options) => {
|
|
|
163
172
|
async request => services.app.listVersions(request.query)
|
|
164
173
|
);
|
|
165
174
|
|
|
175
|
+
fastify.get(
|
|
176
|
+
`${options.prefix}/app/version/download`,
|
|
177
|
+
{
|
|
178
|
+
onRequest: auth(),
|
|
179
|
+
schema: {
|
|
180
|
+
summary: '下载版本部署包(有 fileId 返回原始 zip,否则由版本目录打包)',
|
|
181
|
+
query: {
|
|
182
|
+
type: 'object',
|
|
183
|
+
required: ['name', 'versionId'],
|
|
184
|
+
properties: {
|
|
185
|
+
name: { type: 'string' },
|
|
186
|
+
versionId: { type: 'string' }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
async (request, reply) => {
|
|
192
|
+
const { fileName, buffer, artifactPath, files } = await services.app.resolveVersionPackage(request.query);
|
|
193
|
+
reply.header('Content-Type', 'application/zip');
|
|
194
|
+
reply.header('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"; filename*=UTF-8''${encodeURIComponent(fileName)}`);
|
|
195
|
+
if (buffer) {
|
|
196
|
+
reply.header('Content-Length', buffer.length);
|
|
197
|
+
return reply.send(buffer);
|
|
198
|
+
}
|
|
199
|
+
const zipFile = new yazl.ZipFile();
|
|
200
|
+
for (const file of files) {
|
|
201
|
+
zipFile.addFile(path.join(artifactPath, file), file);
|
|
202
|
+
}
|
|
203
|
+
zipFile.end();
|
|
204
|
+
return reply.send(zipFile.outputStream);
|
|
205
|
+
}
|
|
206
|
+
);
|
|
207
|
+
|
|
166
208
|
fastify.post(
|
|
167
209
|
`${options.prefix}/app/deploy`,
|
|
168
210
|
{
|
|
@@ -177,6 +219,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
177
219
|
versionId: { type: 'string' },
|
|
178
220
|
version: { type: 'string' },
|
|
179
221
|
runMigration: { type: 'boolean', default: true },
|
|
222
|
+
progressId: { type: 'string', description: '进度标识;先用它订阅 deploy/stream 再发起部署' },
|
|
180
223
|
migrations: {
|
|
181
224
|
type: 'array',
|
|
182
225
|
description: '逐个指定待执行脚本的处理方式;未列出的待执行脚本按 execute 处理',
|
|
@@ -196,6 +239,44 @@ module.exports = fp(async (fastify, options) => {
|
|
|
196
239
|
async request => services.app.deploy(request.body)
|
|
197
240
|
);
|
|
198
241
|
|
|
242
|
+
fastify.get(
|
|
243
|
+
`${options.prefix}/app/deploy/stream`,
|
|
244
|
+
{
|
|
245
|
+
onRequest: auth(),
|
|
246
|
+
schema: {
|
|
247
|
+
summary: 'SSE 部署进度',
|
|
248
|
+
query: {
|
|
249
|
+
type: 'object',
|
|
250
|
+
required: ['name'],
|
|
251
|
+
properties: {
|
|
252
|
+
name: { type: 'string' },
|
|
253
|
+
progressId: { type: 'string' }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
async (request, reply) => {
|
|
259
|
+
const { name, progressId } = request.query;
|
|
260
|
+
await services.app.detail({ name });
|
|
261
|
+
const sse = createSseReply(reply, { heartbeatMs: options.sseHeartbeatMs });
|
|
262
|
+
const progress = services.app.getDeployProgress();
|
|
263
|
+
const matches = event => !progressId || event.progressId === progressId;
|
|
264
|
+
const snapshot = progress.snapshot(name);
|
|
265
|
+
sse.writeEvent('snapshot', {
|
|
266
|
+
steps: DEPLOY_STEPS,
|
|
267
|
+
events: snapshot ? snapshot.events.filter(matches) : []
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const onProgress = event => matches(event) && sse.writeEvent('progress', event);
|
|
271
|
+
const eventName = `deploy:${name}`;
|
|
272
|
+
progress.emitter.on(eventName, onProgress);
|
|
273
|
+
request.raw.on('close', () => {
|
|
274
|
+
progress.emitter.off(eventName, onProgress);
|
|
275
|
+
sse.close();
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
);
|
|
279
|
+
|
|
199
280
|
fastify.get(
|
|
200
281
|
`${options.prefix}/app/list`,
|
|
201
282
|
{
|
|
@@ -33,6 +33,16 @@ 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 }];版本目录可随时删除,部署时写回磁盘'
|
|
36
46
|
}
|
|
37
47
|
},
|
|
38
48
|
options: {
|
package/libs/services/app.js
CHANGED
|
@@ -10,7 +10,8 @@ const { ensureLogFiles, readLogTail, readLastLines, appendLog, countLines } = re
|
|
|
10
10
|
const { currentFileName, isCurrentFileName, resolveLogFile, listLogFiles } = require('../utils/logRotate');
|
|
11
11
|
const { toIsoInTz, convertLogText } = require('../utils/logTime');
|
|
12
12
|
const { runSqlMigrations, MIGRATION_ACTIONS } = require('../utils/migrate');
|
|
13
|
-
const {
|
|
13
|
+
const { readSqlDir, writeSqlDir } = require('../utils/versionSql');
|
|
14
|
+
const { createDeployProgress } = require('../utils/deployProgress');
|
|
14
15
|
const pm2Util = require('../utils/pm2');
|
|
15
16
|
|
|
16
17
|
const NAME_RE = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
@@ -250,7 +251,42 @@ module.exports = fp(async (fastify, options) => {
|
|
|
250
251
|
return toPublicApp(app);
|
|
251
252
|
};
|
|
252
253
|
|
|
253
|
-
const
|
|
254
|
+
const versionSqlDir = ver => path.join(ver.artifactPath, 'server', ver.migrationPath || options.sqlPath || 'sql');
|
|
255
|
+
|
|
256
|
+
const hasArtifact = ver => fs.pathExists(path.join(ver.artifactPath, 'server'));
|
|
257
|
+
|
|
258
|
+
/** SQL 以数据库为准;旧版本行(migrationFiles 为空)首次访问时从版本目录回填 */
|
|
259
|
+
const getMigrationFiles = async ver => {
|
|
260
|
+
if (Array.isArray(ver.migrationFiles)) {
|
|
261
|
+
return ver.migrationFiles;
|
|
262
|
+
}
|
|
263
|
+
if (!(await hasArtifact(ver))) {
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
const files = await readSqlDir(versionSqlDir(ver));
|
|
267
|
+
await ver.update({ migrationFiles: files, hasMigration: files.length > 0 });
|
|
268
|
+
return files;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/** 写库,版本目录在磁盘上时同步覆盖其 .sql */
|
|
272
|
+
const saveMigrationFiles = async (ver, files) => {
|
|
273
|
+
const sorted = [...files].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
274
|
+
await ver.update({ migrationFiles: sorted, hasMigration: sorted.length > 0 });
|
|
275
|
+
if (await hasArtifact(ver)) {
|
|
276
|
+
await writeSqlDir(versionSqlDir(ver), sorted);
|
|
277
|
+
}
|
|
278
|
+
return sorted;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const toPublicVersion = (row, files = row.migrationFiles) => {
|
|
282
|
+
const { migrationFiles, ...json } = row.toJSON();
|
|
283
|
+
if (Array.isArray(files) && files.length) {
|
|
284
|
+
json.sqlFiles = files.map(item => item.name);
|
|
285
|
+
}
|
|
286
|
+
return json;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const uploadVersion = async ({ name, version, label, zipBuffer, fileId }) => {
|
|
254
290
|
if (!version) {
|
|
255
291
|
httpError(400, 'version is required');
|
|
256
292
|
}
|
|
@@ -270,7 +306,10 @@ module.exports = fp(async (fastify, options) => {
|
|
|
270
306
|
// Prepare outside the version table so failed or in-progress uploads never appear in the version list.
|
|
271
307
|
const versionsDir = path.join(app.rootPath, 'versions');
|
|
272
308
|
const stagingPath = path.join(versionsDir, `.staging-${crypto.randomUUID()}`);
|
|
309
|
+
// 有宿主文件的版本只校验不常驻磁盘,部署时再解压;没有的只能保留目录
|
|
310
|
+
const keepArtifact = !fileId;
|
|
273
311
|
let prepared;
|
|
312
|
+
let migrationFiles;
|
|
274
313
|
try {
|
|
275
314
|
prepared = await prepareVersionArtifact({
|
|
276
315
|
zipBuffer,
|
|
@@ -278,8 +317,10 @@ module.exports = fp(async (fastify, options) => {
|
|
|
278
317
|
maxZipSize: options.maxZipSize,
|
|
279
318
|
maxZipEntries: options.maxZipEntries,
|
|
280
319
|
npmInstallTimeoutMs: options.npmInstallTimeoutMs,
|
|
281
|
-
sqlPath: options.sqlPath
|
|
320
|
+
sqlPath: options.sqlPath,
|
|
321
|
+
install: keepArtifact
|
|
282
322
|
});
|
|
323
|
+
migrationFiles = await readSqlDir(path.join(stagingPath, 'server', prepared.migrationPath));
|
|
283
324
|
} catch (e) {
|
|
284
325
|
await fs.remove(stagingPath).catch(() => {});
|
|
285
326
|
httpError(400, e.message);
|
|
@@ -287,26 +328,34 @@ module.exports = fp(async (fastify, options) => {
|
|
|
287
328
|
|
|
288
329
|
let artifactPath = null;
|
|
289
330
|
try {
|
|
290
|
-
|
|
331
|
+
const result = await models.appVersion.sequelize.transaction(async transaction => {
|
|
291
332
|
const versionRow = await models.appVersion.create(
|
|
292
333
|
{
|
|
293
334
|
appName: name,
|
|
294
335
|
version,
|
|
295
336
|
label: label || null,
|
|
296
337
|
artifactPath: stagingPath,
|
|
297
|
-
hasMigration:
|
|
298
|
-
migrationPath: prepared.migrationPath
|
|
338
|
+
hasMigration: migrationFiles.length > 0,
|
|
339
|
+
migrationPath: prepared.migrationPath,
|
|
340
|
+
fileId: fileId ? String(fileId) : null,
|
|
341
|
+
migrationFiles
|
|
299
342
|
},
|
|
300
343
|
{ transaction }
|
|
301
344
|
);
|
|
302
345
|
artifactPath = path.join(versionsDir, String(versionRow.id));
|
|
303
|
-
|
|
346
|
+
if (keepArtifact) {
|
|
347
|
+
await fs.move(stagingPath, artifactPath);
|
|
348
|
+
}
|
|
304
349
|
await versionRow.update({ artifactPath }, { transaction });
|
|
305
|
-
return versionRow
|
|
350
|
+
return toPublicVersion(versionRow);
|
|
306
351
|
});
|
|
352
|
+
if (!keepArtifact) {
|
|
353
|
+
await fs.remove(stagingPath).catch(() => {});
|
|
354
|
+
}
|
|
355
|
+
return result;
|
|
307
356
|
} catch (e) {
|
|
308
357
|
await fs.remove(stagingPath).catch(() => {});
|
|
309
|
-
if (artifactPath) {
|
|
358
|
+
if (artifactPath && keepArtifact) {
|
|
310
359
|
await fs.remove(artifactPath).catch(() => {});
|
|
311
360
|
}
|
|
312
361
|
if (e.name === 'SequelizeUniqueConstraintError') {
|
|
@@ -326,16 +375,52 @@ module.exports = fp(async (fastify, options) => {
|
|
|
326
375
|
});
|
|
327
376
|
const pageData = await Promise.all(
|
|
328
377
|
rows.map(async row => {
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
json.sqlFiles = await listSqlFiles(path.join(json.artifactPath, 'server', json.migrationPath || 'sql'));
|
|
332
|
-
}
|
|
333
|
-
return json;
|
|
378
|
+
const files = await getMigrationFiles(row);
|
|
379
|
+
return toPublicVersion(row, files);
|
|
334
380
|
})
|
|
335
381
|
);
|
|
336
382
|
return { pageData, totalCount: count };
|
|
337
383
|
};
|
|
338
384
|
|
|
385
|
+
const listArtifactFiles = async (root, dir = '') => {
|
|
386
|
+
const files = [];
|
|
387
|
+
for (const entry of await fs.readdir(path.join(root, dir), { withFileTypes: true })) {
|
|
388
|
+
const rel = dir ? `${dir}/${entry.name}` : entry.name;
|
|
389
|
+
// 部署时装的依赖与复制进 server 的 build 都不属于原始包
|
|
390
|
+
if (rel === 'server/node_modules' || rel === 'server/build') {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (entry.isDirectory()) {
|
|
394
|
+
files.push(...(await listArtifactFiles(root, rel)));
|
|
395
|
+
} else if (entry.isFile()) {
|
|
396
|
+
files.push(rel);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return files;
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
/** 有 fileId 时返回原始 zip;否则从版本目录还原包内容(不含 server/node_modules、server/build) */
|
|
403
|
+
const resolveVersionPackage = async ({ name, versionId }) => {
|
|
404
|
+
await getByName(name);
|
|
405
|
+
const ver = await models.appVersion.findByPk(versionId);
|
|
406
|
+
if (!ver || ver.appName !== name) {
|
|
407
|
+
httpError(404, 'version not found');
|
|
408
|
+
}
|
|
409
|
+
const fileName = `${name}-${ver.version}.zip`;
|
|
410
|
+
if (ver.fileId && typeof options.resolveVersionFile === 'function') {
|
|
411
|
+
try {
|
|
412
|
+
const buffer = await options.resolveVersionFile({ fileId: ver.fileId, name, version: ver.version });
|
|
413
|
+
return { fileName, buffer };
|
|
414
|
+
} catch (e) {
|
|
415
|
+
fastify.log.warn({ err: e, app: name, version: ver.version }, 'resolve version file failed, fallback to artifact');
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (!(await fs.pathExists(path.join(ver.artifactPath, 'server')))) {
|
|
419
|
+
httpError(404, 'version package not found');
|
|
420
|
+
}
|
|
421
|
+
return { fileName, artifactPath: ver.artifactPath, files: await listArtifactFiles(ver.artifactPath) };
|
|
422
|
+
};
|
|
423
|
+
|
|
339
424
|
const healthCheck = async (port, { timeoutMs, intervalMs, healthPath }) => {
|
|
340
425
|
const started = Date.now();
|
|
341
426
|
const url = `http://127.0.0.1:${port}${healthPath || '/'}`;
|
|
@@ -365,6 +450,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
365
450
|
|
|
366
451
|
/** 同一 app 并发同步合并为一次,避免 restart/reconcile 竞态 */
|
|
367
452
|
const statusSyncInflight = new Map();
|
|
453
|
+
const deployProgress = createDeployProgress();
|
|
368
454
|
|
|
369
455
|
/**
|
|
370
456
|
* 根据 PM2 真实进程状态 + HTTP 健康检查,异步写回 DB status。
|
|
@@ -459,11 +545,14 @@ module.exports = fp(async (fastify, options) => {
|
|
|
459
545
|
|
|
460
546
|
const finishDeployAsync = async appId => syncRealStatus(appId, { recoverIfMissing: false });
|
|
461
547
|
|
|
462
|
-
const scheduleStatusSync = (appId, opts) => {
|
|
548
|
+
const scheduleStatusSync = (appId, { onSettled, ...opts } = {}) => {
|
|
463
549
|
setImmediate(() => {
|
|
464
|
-
syncRealStatus(appId, opts)
|
|
465
|
-
|
|
466
|
-
|
|
550
|
+
syncRealStatus(appId, opts)
|
|
551
|
+
.catch(err => {
|
|
552
|
+
fastify.log.warn({ err, appId }, 'syncRealStatus failed');
|
|
553
|
+
return { status: 'error', message: err.message };
|
|
554
|
+
})
|
|
555
|
+
.then(result => onSettled && onSettled(result));
|
|
467
556
|
});
|
|
468
557
|
};
|
|
469
558
|
|
|
@@ -472,14 +561,18 @@ module.exports = fp(async (fastify, options) => {
|
|
|
472
561
|
return syncRealStatus(app.id, { recoverIfMissing });
|
|
473
562
|
};
|
|
474
563
|
|
|
475
|
-
const syncAllStatuses = async ({ recoverIfMissing = true } = {}) => {
|
|
564
|
+
const syncAllStatuses = async ({ recoverIfMissing = true, excludeIds = [] } = {}) => {
|
|
476
565
|
const apps = await models.app.findAll({
|
|
477
566
|
where: {
|
|
478
567
|
status: ['running', 'deploying', 'stopped', 'error']
|
|
479
568
|
}
|
|
480
569
|
});
|
|
570
|
+
const excluded = new Set(excludeIds.map(String));
|
|
481
571
|
const results = [];
|
|
482
572
|
for (const app of apps) {
|
|
573
|
+
if (excluded.has(String(app.id))) {
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
483
576
|
const shouldRecover = recoverIfMissing && (app.status === 'running' || app.status === 'deploying');
|
|
484
577
|
results.push(await syncRealStatus(app.id, { recoverIfMissing: shouldRecover }));
|
|
485
578
|
}
|
|
@@ -551,7 +644,117 @@ module.exports = fp(async (fastify, options) => {
|
|
|
551
644
|
});
|
|
552
645
|
};
|
|
553
646
|
|
|
554
|
-
const
|
|
647
|
+
const linkCurrent = async (app, artifactPath) => {
|
|
648
|
+
const currentLink = path.join(app.rootPath, 'current');
|
|
649
|
+
await fs.remove(currentLink).catch(() => {});
|
|
650
|
+
await fs.symlink(artifactPath, currentLink).catch(async () => {
|
|
651
|
+
// Windows fallback: copy marker file
|
|
652
|
+
await fs.writeFile(path.join(app.rootPath, 'current-path.txt'), artifactPath);
|
|
653
|
+
});
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
/** 只做 fs 检查:返回当前版本目录(artifactPath/server)缺失的应用 */
|
|
657
|
+
const listMissingArtifacts = async () => {
|
|
658
|
+
const { Op } = fastify.sequelize.Sequelize;
|
|
659
|
+
const apps = await models.app.findAll({ where: { currentVersionId: { [Op.ne]: null } } });
|
|
660
|
+
const missing = [];
|
|
661
|
+
for (const app of apps) {
|
|
662
|
+
const ver = await models.appVersion.findByPk(app.currentVersionId);
|
|
663
|
+
if (ver && !(await fs.pathExists(path.join(ver.artifactPath, 'server')))) {
|
|
664
|
+
missing.push({ app, ver });
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return missing;
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
const canResolveFile = ver => !!ver.fileId && typeof options.resolveVersionFile === 'function';
|
|
671
|
+
|
|
672
|
+
/** 版本目录不在磁盘时按 fileId 取回 zip 解压并装依赖;再以数据库中的 SQL 覆盖目录内 .sql */
|
|
673
|
+
const materializeArtifact = async (app, ver, { onProgress } = {}) => {
|
|
674
|
+
if (await hasArtifact(ver)) {
|
|
675
|
+
onProgress && onProgress('reuse');
|
|
676
|
+
} else {
|
|
677
|
+
if (!canResolveFile(ver)) {
|
|
678
|
+
throw new Error(`version ${ver.version} artifact missing and cannot be restored without fileId`);
|
|
679
|
+
}
|
|
680
|
+
const stagingPath = path.join(app.rootPath, 'versions', `.staging-${crypto.randomUUID()}`);
|
|
681
|
+
try {
|
|
682
|
+
onProgress && onProgress('download');
|
|
683
|
+
const zipBuffer = await options.resolveVersionFile({ fileId: ver.fileId, name: app.name, version: ver.version });
|
|
684
|
+
await prepareVersionArtifact({
|
|
685
|
+
zipBuffer,
|
|
686
|
+
artifactPath: stagingPath,
|
|
687
|
+
maxZipSize: options.maxZipSize,
|
|
688
|
+
maxZipEntries: options.maxZipEntries,
|
|
689
|
+
npmInstallTimeoutMs: options.npmInstallTimeoutMs,
|
|
690
|
+
sqlPath: options.sqlPath,
|
|
691
|
+
onProgress
|
|
692
|
+
});
|
|
693
|
+
await fs.move(stagingPath, ver.artifactPath, { overwrite: true });
|
|
694
|
+
} catch (e) {
|
|
695
|
+
await fs.remove(stagingPath).catch(() => {});
|
|
696
|
+
throw e;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
await writeSqlDir(versionSqlDir(ver), await getMigrationFiles(ver));
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
/** 删除非当前版本中可从宿主文件重建的目录;删前先把 SQL 回填进库 */
|
|
703
|
+
const pruneVersionArtifacts = async app => {
|
|
704
|
+
const versions = await models.appVersion.findAll({ where: { appName: app.name } });
|
|
705
|
+
const removed = [];
|
|
706
|
+
for (const ver of versions) {
|
|
707
|
+
if (String(ver.id) === String(app.currentVersionId) || !canResolveFile(ver) || !(await fs.pathExists(ver.artifactPath))) {
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
await getMigrationFiles(ver);
|
|
711
|
+
await fs.remove(ver.artifactPath);
|
|
712
|
+
removed.push(ver.version);
|
|
713
|
+
}
|
|
714
|
+
if (removed.length) {
|
|
715
|
+
fastify.log.info({ app: app.name, versions: removed }, 'pruned non-current version artifacts');
|
|
716
|
+
}
|
|
717
|
+
return removed;
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
const pruneAllVersionArtifacts = async () => {
|
|
721
|
+
const results = [];
|
|
722
|
+
for (const app of await models.app.findAll()) {
|
|
723
|
+
try {
|
|
724
|
+
results.push({ name: app.name, removed: await pruneVersionArtifacts(app) });
|
|
725
|
+
} catch (e) {
|
|
726
|
+
fastify.log.warn({ err: e, app: app.name }, 'prune version artifacts failed');
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return results;
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
/** 补回当前版本目录并恢复 current 链接;不跑 SQL 迁移 */
|
|
733
|
+
const restoreArtifact = async ({ app, ver }) => {
|
|
734
|
+
if (!canResolveFile(ver)) {
|
|
735
|
+
fastify.log.warn({ app: app.name, version: ver.version }, 'current version artifact missing and cannot be restored without fileId');
|
|
736
|
+
return { name: app.name, version: ver.version, action: 'skipped', reason: 'no fileId' };
|
|
737
|
+
}
|
|
738
|
+
try {
|
|
739
|
+
await materializeArtifact(app, ver);
|
|
740
|
+
await linkCurrent(app, ver.artifactPath);
|
|
741
|
+
fastify.log.info({ app: app.name, version: ver.version }, 'current version artifact restored from file');
|
|
742
|
+
return { name: app.name, version: ver.version, action: 'restored' };
|
|
743
|
+
} catch (e) {
|
|
744
|
+
fastify.log.warn({ err: e, app: app.name, version: ver.version }, 'restore current version artifact failed');
|
|
745
|
+
return { name: app.name, version: ver.version, action: 'failed', reason: e.message };
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
const restoreMissingArtifacts = async () => {
|
|
750
|
+
const results = [];
|
|
751
|
+
for (const item of await listMissingArtifacts()) {
|
|
752
|
+
results.push(await restoreArtifact(item));
|
|
753
|
+
}
|
|
754
|
+
return results;
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
const deploy = async ({ name, versionId, version, runMigration = true, migrations, progressId }) => {
|
|
555
758
|
const app = await getByName(name);
|
|
556
759
|
let ver = null;
|
|
557
760
|
if (versionId) {
|
|
@@ -563,21 +766,29 @@ module.exports = fp(async (fastify, options) => {
|
|
|
563
766
|
httpError(404, 'version not found');
|
|
564
767
|
}
|
|
565
768
|
|
|
769
|
+
const progress = deployProgress.begin(app.name, progressId || crypto.randomUUID());
|
|
770
|
+
let step = 'package';
|
|
771
|
+
progress.report(step, 'running');
|
|
772
|
+
try {
|
|
773
|
+
await materializeArtifact(app, ver, { onProgress: detail => progress.report('package', 'running', { detail }) });
|
|
774
|
+
} catch (e) {
|
|
775
|
+
progress.report(step, 'error', { message: e.message });
|
|
776
|
+
httpError(400, e.message);
|
|
777
|
+
}
|
|
778
|
+
progress.report(step, 'done');
|
|
779
|
+
|
|
566
780
|
await app.update({
|
|
567
781
|
status: 'deploying',
|
|
568
782
|
currentVersionId: String(ver.id),
|
|
569
783
|
message: null
|
|
570
784
|
});
|
|
571
785
|
|
|
572
|
-
|
|
573
|
-
await fs.remove(currentLink).catch(() => {});
|
|
574
|
-
await fs.symlink(ver.artifactPath, currentLink).catch(async () => {
|
|
575
|
-
// Windows fallback: copy marker file
|
|
576
|
-
await fs.writeFile(path.join(app.rootPath, 'current-path.txt'), ver.artifactPath);
|
|
577
|
-
});
|
|
786
|
+
await linkCurrent(app, ver.artifactPath);
|
|
578
787
|
|
|
579
788
|
try {
|
|
789
|
+
step = 'migrate';
|
|
580
790
|
if (runMigration && options.migrateBeforeStart && ver.hasMigration) {
|
|
791
|
+
progress.report(step, 'running');
|
|
581
792
|
const env = mergeEnv({
|
|
582
793
|
passthroughEnvKeys: options.passthroughEnvKeys,
|
|
583
794
|
appEnv: app.env,
|
|
@@ -612,6 +823,9 @@ module.exports = fp(async (fastify, options) => {
|
|
|
612
823
|
} catch (e) {
|
|
613
824
|
// ignore ownership merge failures
|
|
614
825
|
}
|
|
826
|
+
progress.report(step, 'done');
|
|
827
|
+
} else {
|
|
828
|
+
progress.report(step, 'skipped');
|
|
615
829
|
}
|
|
616
830
|
|
|
617
831
|
if (runMigration === false) {
|
|
@@ -619,18 +833,29 @@ module.exports = fp(async (fastify, options) => {
|
|
|
619
833
|
app.env = Object.assign({}, app.env, { RUN_SQL_ON_SYNC: 'false' });
|
|
620
834
|
}
|
|
621
835
|
|
|
836
|
+
step = 'start';
|
|
837
|
+
progress.report(step, 'running');
|
|
622
838
|
const beforeForReady = await snapshotTablesSafe(app);
|
|
623
839
|
await startProcess(app);
|
|
624
|
-
|
|
840
|
+
progress.report(step, 'done');
|
|
841
|
+
step = 'ready';
|
|
842
|
+
progress.report(step, 'running');
|
|
843
|
+
scheduleStatusSync(app.id, {
|
|
844
|
+
beforeTables: beforeForReady,
|
|
845
|
+
onSettled: result => (result?.status === 'running' ? progress.report('ready', 'done') : progress.report('ready', 'error', { message: result?.message || result?.status || null }))
|
|
846
|
+
});
|
|
847
|
+
await pruneVersionArtifacts(app).catch(err => fastify.log.warn({ err, app: app.name }, 'prune version artifacts failed'));
|
|
625
848
|
return {
|
|
626
849
|
name: app.name,
|
|
627
850
|
status: 'deploying',
|
|
628
851
|
versionId: String(ver.id),
|
|
852
|
+
progressId: progress.progressId,
|
|
629
853
|
port: app.port,
|
|
630
854
|
pathUrl: `${mountPrefix(app.name)}/`,
|
|
631
855
|
domain: app.domain
|
|
632
856
|
};
|
|
633
857
|
} catch (e) {
|
|
858
|
+
progress.report(step, 'error', { message: e.message });
|
|
634
859
|
await app.update({ status: 'error', message: e.message });
|
|
635
860
|
httpError(400, e.message);
|
|
636
861
|
}
|
|
@@ -715,6 +940,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
715
940
|
const snapshot = app.toJSON();
|
|
716
941
|
await models.appVersion.destroy({ where: { appName: name } });
|
|
717
942
|
await fs.remove(app.rootPath).catch(() => {});
|
|
943
|
+
deployProgress.clear(app.name);
|
|
718
944
|
await app.destroy();
|
|
719
945
|
if (typeof options.onAppRemoved === 'function') {
|
|
720
946
|
try {
|
|
@@ -858,6 +1084,14 @@ module.exports = fp(async (fastify, options) => {
|
|
|
858
1084
|
syncStatus,
|
|
859
1085
|
syncAllStatuses,
|
|
860
1086
|
remove,
|
|
1087
|
+
resolveVersionPackage,
|
|
1088
|
+
listMissingArtifacts,
|
|
1089
|
+
restoreArtifact,
|
|
1090
|
+
restoreMissingArtifacts,
|
|
1091
|
+
pruneVersionArtifacts,
|
|
1092
|
+
pruneAllVersionArtifacts,
|
|
1093
|
+
getMigrationFiles,
|
|
1094
|
+
saveMigrationFiles,
|
|
861
1095
|
logs,
|
|
862
1096
|
logFiles,
|
|
863
1097
|
resolveAppLogFile,
|
|
@@ -865,6 +1099,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
865
1099
|
removeLogFiles,
|
|
866
1100
|
load,
|
|
867
1101
|
getLoadStore,
|
|
1102
|
+
getDeployProgress: () => deployProgress,
|
|
868
1103
|
toPublicApp,
|
|
869
1104
|
mountPrefix,
|
|
870
1105
|
findByHost,
|
|
@@ -35,8 +35,28 @@ module.exports = fp(async (fastify, options) => {
|
|
|
35
35
|
return models.app.findOne({ where: { pm2Name } });
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
/** 宿主 sync() 不会给已有表加列:老库升级时补齐后续新增的可空列 */
|
|
39
|
+
const ensureSchema = async () => {
|
|
40
|
+
const { models } = fastify[options.name];
|
|
41
|
+
const model = models.appVersion;
|
|
42
|
+
const queryInterface = model.sequelize.getQueryInterface();
|
|
43
|
+
const table = model.getTableName();
|
|
44
|
+
const columns = await queryInterface.describeTable(table);
|
|
45
|
+
for (const name of ['fileId', 'migrationFiles']) {
|
|
46
|
+
const attr = model.rawAttributes[name];
|
|
47
|
+
if (!columns[attr.field]) {
|
|
48
|
+
await queryInterface.addColumn(table, attr.field, { type: attr.type, allowNull: true, comment: attr.comment });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
38
53
|
const onReady = async () => {
|
|
39
54
|
await fsEnsureAppsRoot();
|
|
55
|
+
try {
|
|
56
|
+
await ensureSchema();
|
|
57
|
+
} catch (e) {
|
|
58
|
+
fastify.log.warn({ err: e }, 'ensure app-manager schema failed');
|
|
59
|
+
}
|
|
40
60
|
try {
|
|
41
61
|
await pm2Util.connect();
|
|
42
62
|
connected = true;
|
|
@@ -87,9 +107,9 @@ module.exports = fp(async (fastify, options) => {
|
|
|
87
107
|
loadTimer.unref?.();
|
|
88
108
|
}
|
|
89
109
|
|
|
90
|
-
// 父应用重启后不阻塞 ready:后台按 PM2
|
|
110
|
+
// 父应用重启后不阻塞 ready:后台按 PM2 真实状态对齐 DB,缺部署目录的应用另行补回后再拉起
|
|
91
111
|
setImmediate(() => {
|
|
92
|
-
|
|
112
|
+
recoverOnStartup().catch(err => {
|
|
93
113
|
fastify.log.warn({ err }, 'async reconcile/sync statuses failed');
|
|
94
114
|
});
|
|
95
115
|
rotateLogs().catch(err => fastify.log.warn({ err }, 'log rotation failed'));
|
|
@@ -103,14 +123,57 @@ module.exports = fp(async (fastify, options) => {
|
|
|
103
123
|
await fs.ensureDir(options.appsRoot);
|
|
104
124
|
};
|
|
105
125
|
|
|
106
|
-
const reconcile = async () => {
|
|
126
|
+
const reconcile = async ({ excludeIds } = {}) => {
|
|
107
127
|
const { services } = fastify[options.name];
|
|
108
128
|
if (typeof services.app.syncAllStatuses === 'function') {
|
|
109
|
-
return services.app.syncAllStatuses({ recoverIfMissing: true });
|
|
129
|
+
return services.app.syncAllStatuses({ recoverIfMissing: true, excludeIds });
|
|
110
130
|
}
|
|
111
131
|
return [];
|
|
112
132
|
};
|
|
113
133
|
|
|
134
|
+
/** 逐个补回缺失的部署目录(npm install 耗时),每补完一个就单独对齐状态并按需拉起 */
|
|
135
|
+
const restoreArtifacts = async missing => {
|
|
136
|
+
const { services } = fastify[options.name];
|
|
137
|
+
const results = [];
|
|
138
|
+
for (const item of missing) {
|
|
139
|
+
const result = await services.app.restoreArtifact(item);
|
|
140
|
+
results.push(result);
|
|
141
|
+
const { app } = item;
|
|
142
|
+
const wasActive = app.status === 'running' || app.status === 'deploying';
|
|
143
|
+
try {
|
|
144
|
+
const synced = await services.app.syncStatus({ name: app.name, recoverIfMissing: result.action === 'restored' && wasActive });
|
|
145
|
+
if (result.action !== 'restored' && wasActive && synced?.status !== 'running') {
|
|
146
|
+
await app.update({ status: 'error', message: `current version artifact missing: ${result.reason}` });
|
|
147
|
+
}
|
|
148
|
+
} catch (e) {
|
|
149
|
+
fastify.log.warn({ err: e, app: app.name }, 'sync status after artifact restore failed');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return results;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const recoverOnStartup = async () => {
|
|
156
|
+
const { services } = fastify[options.name];
|
|
157
|
+
const missing = await services.app.listMissingArtifacts();
|
|
158
|
+
// 两路并行:部署目录完好的应用不必等其它应用重装依赖
|
|
159
|
+
const [reconciled, restored] = await Promise.all([
|
|
160
|
+
reconcile({ excludeIds: missing.map(({ app }) => app.id) }).catch(err => {
|
|
161
|
+
fastify.log.warn({ err }, 'async reconcile/sync statuses failed');
|
|
162
|
+
return [];
|
|
163
|
+
}),
|
|
164
|
+
restoreArtifacts(missing).catch(err => {
|
|
165
|
+
fastify.log.warn({ err }, 'restore missing artifacts failed');
|
|
166
|
+
return [];
|
|
167
|
+
})
|
|
168
|
+
]);
|
|
169
|
+
// 只保留当前版本目录,其余版本部署时再从宿主文件解压
|
|
170
|
+
const pruned = await services.app.pruneAllVersionArtifacts().catch(err => {
|
|
171
|
+
fastify.log.warn({ err }, 'prune version artifacts failed');
|
|
172
|
+
return [];
|
|
173
|
+
});
|
|
174
|
+
return { reconciled, restored, pruned };
|
|
175
|
+
};
|
|
176
|
+
|
|
114
177
|
const runRotation = async ({ now = new Date() } = {}) => {
|
|
115
178
|
const { models } = fastify[options.name];
|
|
116
179
|
const apps = await models.app.findAll({ attributes: ['name', 'rootPath'] });
|
|
@@ -244,6 +307,6 @@ module.exports = fp(async (fastify, options) => {
|
|
|
244
307
|
};
|
|
245
308
|
|
|
246
309
|
Object.assign(fastify[options.name].services, {
|
|
247
|
-
bootstrap: { onReady, onClose, reconcile, rotateLogs, sampleLoad }
|
|
310
|
+
bootstrap: { onReady, onClose, ensureSchema, recoverOnStartup, reconcile, rotateLogs, sampleLoad }
|
|
248
311
|
});
|
|
249
312
|
});
|
|
@@ -1,12 +1,10 @@
|
|
|
1
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
2
|
const { isValidSqlFileName, readExecutedMigrations, recordMigration, removeMigrationRecord } = require('../utils/migrate');
|
|
6
3
|
|
|
7
4
|
module.exports = fp(async (fastify, options) => {
|
|
8
5
|
const { models } = fastify[options.name];
|
|
9
6
|
const getDbops = () => fastify[options.name].services.dbops;
|
|
7
|
+
const getAppService = () => fastify[options.name].services.app;
|
|
10
8
|
|
|
11
9
|
const httpError = (statusCode, message) => {
|
|
12
10
|
if (fastify.httpErrors) {
|
|
@@ -27,8 +25,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
27
25
|
if (!ver || ver.appName !== name) {
|
|
28
26
|
httpError(404, 'version not found');
|
|
29
27
|
}
|
|
30
|
-
|
|
31
|
-
return { app, ver, sqlDir };
|
|
28
|
+
return { app, ver };
|
|
32
29
|
};
|
|
33
30
|
|
|
34
31
|
const assertFileName = file => {
|
|
@@ -37,25 +34,20 @@ module.exports = fp(async (fastify, options) => {
|
|
|
37
34
|
}
|
|
38
35
|
};
|
|
39
36
|
|
|
40
|
-
const
|
|
37
|
+
const getFiles = ver => getAppService().getMigrationFiles(ver);
|
|
38
|
+
|
|
39
|
+
const findFile = async (ver, file) => {
|
|
41
40
|
assertFileName(file);
|
|
42
|
-
const
|
|
43
|
-
if (!
|
|
41
|
+
const item = (await getFiles(ver)).find(f => f.name === file);
|
|
42
|
+
if (!item) {
|
|
44
43
|
httpError(404, `sql file ${file} not found`);
|
|
45
44
|
}
|
|
46
|
-
return
|
|
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
|
-
}
|
|
45
|
+
return item;
|
|
54
46
|
};
|
|
55
47
|
|
|
56
48
|
const list = async ({ name, versionId }) => {
|
|
57
|
-
const { app, ver
|
|
58
|
-
const files = await
|
|
49
|
+
const { app, ver } = await resolveVersion({ name, versionId });
|
|
50
|
+
const files = await getFiles(ver);
|
|
59
51
|
let executedMap = new Map();
|
|
60
52
|
let dbError = null;
|
|
61
53
|
try {
|
|
@@ -63,18 +55,13 @@ module.exports = fp(async (fastify, options) => {
|
|
|
63
55
|
} catch (e) {
|
|
64
56
|
dbError = e.message;
|
|
65
57
|
}
|
|
66
|
-
const pageData =
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
executed: dbError ? null : executedMap.has(file),
|
|
74
|
-
executedAt: executedMap.get(file) || null
|
|
75
|
-
};
|
|
76
|
-
})
|
|
77
|
-
);
|
|
58
|
+
const pageData = files.map(item => ({
|
|
59
|
+
name: item.name,
|
|
60
|
+
size: Buffer.byteLength(item.content || '', 'utf8'),
|
|
61
|
+
updatedAt: item.updatedAt || null,
|
|
62
|
+
executed: dbError ? null : executedMap.has(item.name),
|
|
63
|
+
executedAt: executedMap.get(item.name) || null
|
|
64
|
+
}));
|
|
78
65
|
return {
|
|
79
66
|
versionId: String(ver.id),
|
|
80
67
|
version: ver.version,
|
|
@@ -87,32 +74,33 @@ module.exports = fp(async (fastify, options) => {
|
|
|
87
74
|
};
|
|
88
75
|
|
|
89
76
|
const content = async ({ name, versionId, file }) => {
|
|
90
|
-
const {
|
|
91
|
-
const
|
|
92
|
-
return { name: file, content:
|
|
77
|
+
const { ver } = await resolveVersion({ name, versionId });
|
|
78
|
+
const item = await findFile(ver, file);
|
|
79
|
+
return { name: file, content: item.content || '' };
|
|
93
80
|
};
|
|
94
81
|
|
|
95
82
|
const save = async ({ name, versionId, file, content: sql }) => {
|
|
96
83
|
assertFileName(file);
|
|
97
|
-
const { ver
|
|
98
|
-
await
|
|
99
|
-
|
|
100
|
-
await
|
|
84
|
+
const { ver } = await resolveVersion({ name, versionId });
|
|
85
|
+
const files = (await getFiles(ver)).filter(f => f.name !== file);
|
|
86
|
+
files.push({ name: file, content: sql || '', updatedAt: new Date().toISOString() });
|
|
87
|
+
await getAppService().saveMigrationFiles(ver, files);
|
|
101
88
|
return { name: file };
|
|
102
89
|
};
|
|
103
90
|
|
|
104
91
|
const remove = async ({ name, versionId, file }) => {
|
|
105
|
-
const { ver
|
|
106
|
-
|
|
107
|
-
await
|
|
108
|
-
|
|
92
|
+
const { ver } = await resolveVersion({ name, versionId });
|
|
93
|
+
await findFile(ver, file);
|
|
94
|
+
await getAppService().saveMigrationFiles(
|
|
95
|
+
ver,
|
|
96
|
+
(await getFiles(ver)).filter(f => f.name !== file)
|
|
97
|
+
);
|
|
109
98
|
return { name: file };
|
|
110
99
|
};
|
|
111
100
|
|
|
112
101
|
const execute = async ({ name, versionId, file }) => {
|
|
113
|
-
const { app,
|
|
114
|
-
const
|
|
115
|
-
const sql = await fs.readFile(filePath, 'utf8');
|
|
102
|
+
const { app, ver } = await resolveVersion({ name, versionId });
|
|
103
|
+
const { content: sql } = await findFile(ver, file);
|
|
116
104
|
const dbops = getDbops();
|
|
117
105
|
let beforeTables = [];
|
|
118
106
|
let afterTables = [];
|
|
@@ -131,8 +119,8 @@ module.exports = fp(async (fastify, options) => {
|
|
|
131
119
|
};
|
|
132
120
|
|
|
133
121
|
const mark = async ({ name, versionId, file }) => {
|
|
134
|
-
const { app,
|
|
135
|
-
await
|
|
122
|
+
const { app, ver } = await resolveVersion({ name, versionId });
|
|
123
|
+
await findFile(ver, file);
|
|
136
124
|
await getDbops().withSequelize(app, sequelize => recordMigration(sequelize, file));
|
|
137
125
|
return { name: file, executed: true };
|
|
138
126
|
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const { EventEmitter } = require('node:events');
|
|
2
|
+
|
|
3
|
+
/** 部署阶段,按顺序推进:准备部署包 → SQL 迁移 → 启动进程 → 等待就绪 */
|
|
4
|
+
const DEPLOY_STEPS = ['package', 'migrate', 'start', 'ready'];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 每个应用只保留最近一次部署的进度事件,供 SSE 订阅与断线重连回放。
|
|
8
|
+
* 事件:{ progressId, step, status: 'running' | 'done' | 'skipped' | 'error', detail?, message?, at }
|
|
9
|
+
*/
|
|
10
|
+
const createDeployProgress = () => {
|
|
11
|
+
const emitter = new EventEmitter();
|
|
12
|
+
emitter.setMaxListeners(0);
|
|
13
|
+
const latest = new Map();
|
|
14
|
+
|
|
15
|
+
const begin = (name, progressId) => {
|
|
16
|
+
const state = { progressId, events: [] };
|
|
17
|
+
latest.set(name, state);
|
|
18
|
+
const report = (step, status, { detail = null, message = null } = {}) => {
|
|
19
|
+
const event = { progressId, step, status, detail, message, at: new Date().toISOString() };
|
|
20
|
+
state.events.push(event);
|
|
21
|
+
emitter.emit(`deploy:${name}`, event);
|
|
22
|
+
return event;
|
|
23
|
+
};
|
|
24
|
+
return { progressId, report };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const snapshot = name => latest.get(name) || null;
|
|
28
|
+
|
|
29
|
+
const clear = name => latest.delete(name);
|
|
30
|
+
|
|
31
|
+
return { emitter, begin, snapshot, clear };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
module.exports = {
|
|
35
|
+
DEPLOY_STEPS,
|
|
36
|
+
createDeployProgress
|
|
37
|
+
};
|
package/libs/utils/version.js
CHANGED
|
@@ -33,8 +33,10 @@ const runNpmInstall = (cwd, timeoutMs) =>
|
|
|
33
33
|
});
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
/** install=false 时只解压校验,不装依赖(用于不常驻磁盘的版本上传) */
|
|
37
|
+
const prepareVersionArtifact = async ({ zipBuffer, artifactPath, maxZipSize, maxZipEntries, npmInstallTimeoutMs, sqlPath, install = true, onProgress }) => {
|
|
37
38
|
await fs.emptyDir(artifactPath);
|
|
39
|
+
onProgress && onProgress('extract');
|
|
38
40
|
const extractDir = path.join(artifactPath, '_extract');
|
|
39
41
|
await extractZipSafe(zipBuffer, extractDir, { maxZipSize, maxZipEntries });
|
|
40
42
|
const packageRoot = await resolvePackageRoot(extractDir);
|
|
@@ -60,7 +62,10 @@ const prepareVersionArtifact = async ({ zipBuffer, artifactPath, maxZipSize, max
|
|
|
60
62
|
});
|
|
61
63
|
|
|
62
64
|
const serverDir = path.join(artifactPath, 'server');
|
|
63
|
-
|
|
65
|
+
if (install) {
|
|
66
|
+
onProgress && onProgress('install');
|
|
67
|
+
await runNpmInstall(serverDir, npmInstallTimeoutMs);
|
|
68
|
+
}
|
|
64
69
|
|
|
65
70
|
const sqlDir = path.join(serverDir, sqlPath || 'sql');
|
|
66
71
|
const sqlFiles = await listSqlFiles(sqlDir);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const path = require('node:path');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const { listSqlFiles } = require('./validatePackage');
|
|
4
|
+
|
|
5
|
+
/** 读取目录内的 .sql 为 [{ name, content, updatedAt }] */
|
|
6
|
+
const readSqlDir = async sqlDir => {
|
|
7
|
+
const files = [];
|
|
8
|
+
for (const name of await listSqlFiles(sqlDir)) {
|
|
9
|
+
const filePath = path.join(sqlDir, name);
|
|
10
|
+
const [content, stat] = await Promise.all([fs.readFile(filePath, 'utf8'), fs.stat(filePath)]);
|
|
11
|
+
files.push({ name, content, updatedAt: stat.mtime.toISOString() });
|
|
12
|
+
}
|
|
13
|
+
return files;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** 以 files 为准覆盖目录内的 .sql:删掉多余的,写入全部 */
|
|
17
|
+
const writeSqlDir = async (sqlDir, files) => {
|
|
18
|
+
await fs.ensureDir(sqlDir);
|
|
19
|
+
const keep = new Set(files.map(item => item.name));
|
|
20
|
+
for (const name of await listSqlFiles(sqlDir)) {
|
|
21
|
+
if (!keep.has(name)) {
|
|
22
|
+
await fs.remove(path.join(sqlDir, name));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
for (const item of files) {
|
|
26
|
+
await fs.writeFile(path.join(sqlDir, item.name), item.content || '', 'utf8');
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
module.exports = {
|
|
31
|
+
readSqlDir,
|
|
32
|
+
writeSqlDir
|
|
33
|
+
};
|