@kne/fastify-app-manager 0.1.11 → 0.1.13
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 +79 -7
- package/libs/controllers/app.js +19 -0
- package/libs/controllers/gateway.js +80 -11
- package/libs/models/appVersion.js +5 -0
- package/libs/services/app.js +219 -17
- package/libs/services/bootstrap.js +1 -1
- package/libs/utils/deployProgress.js +32 -3
- 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 +4 -3
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -64,6 +64,8 @@ deploy(versionId)
|
|
|
64
64
|
↓
|
|
65
65
|
目录缺失则按 fileId 解压 + npm install → 数据库 SQL 覆盖写回
|
|
66
66
|
↓
|
|
67
|
+
按 env.schema.json 校验环境变量(不通过即 400,不切版本、不启动)
|
|
68
|
+
↓
|
|
67
69
|
status=deploying → 可选 SQL 迁移 → inject entryHtml → pm2.start → 清理其它版本目录
|
|
68
70
|
↓(立即返回)
|
|
69
71
|
后台 healthCheck → running / error
|
|
@@ -111,6 +113,7 @@ deploy(versionId)
|
|
|
111
113
|
| 应用自有库 | 应用 `env` 含完整 `DB_*` 且 `DB_DEDICATED` 不为 `false` 时为独享库(`dbScope=dedicated`);`DB_DEDICATED=false` 保留配置但回落共享库。`dbScope` 始终由 env 推导,与启动注入的连接一致;独享库若带 `DB_TABLE_PREFIX`(与其它系统共用库)只认该前缀的表 |
|
|
112
114
|
| 共享库表前缀 | 启动时注入 `DB_TABLE_PREFIX=t_{appName}_` 并由系统写回应用 env(接口不返回、不可修改);子应用 sequelize 须读取该变量建表 |
|
|
113
115
|
| 表归属 | `options.ownedTables`;共享库启动就绪后按前缀自动认领,亦可 `sync-owned` 扫描;运维仅限归属表 |
|
|
116
|
+
| 变量声明 | 子应用在 `server/env.schema.json` 声明所需变量(标准 JSON Schema,必填放顶层 `required`,敏感值 `writeOnly`);上传缺失即拒绝,启动前(deploy / start / restart / 恢复)按它校验最终变量;`GET .../app/env/schema` 列出需要用户填写的变量,隐藏 `PORT`、托管库 `DB_*`、系统变量等应用中心注入的键 |
|
|
114
117
|
|
|
115
118
|
##### SQL 迁移
|
|
116
119
|
|
|
@@ -122,7 +125,7 @@ deploy(versionId)
|
|
|
122
125
|
|------|------|
|
|
123
126
|
| 版本上传 | Zip Slip 防护、条目数/体积上限、解压丢弃任意层级 `node_modules`、Fullstack 包格式校验、server 生产依赖安装 |
|
|
124
127
|
| 短部署 | `deploy` / `start` / `restart` 立即返回 `deploying`,后台按 PM2 真实状态 + healthCheck 写回 `running`/`error`/`stopped` |
|
|
125
|
-
| 部署进度 | `deploy` 按「准备部署包 → SQL 迁移 → 启动进程 → 等待就绪」上报阶段,经 `GET .../app/deploy/stream` SSE 推送(含下载 / 解压 / 安装依赖等子阶段) |
|
|
128
|
+
| 部署进度 | `deploy` 按「准备部署包 → 校验环境变量 → SQL 迁移 → 启动进程 → 等待就绪」上报阶段,经 `GET .../app/deploy/stream` SSE 推送(含下载 / 解压 / 安装依赖等子阶段) |
|
|
126
129
|
| 生命周期 | `start` / `stop` / `restart` / `remove`;父应用重启后异步 `syncAllStatuses` 对齐 PM2 |
|
|
127
130
|
| 双通道访问 | 自定义域名透明反代 + `/app/{name}` 剥前缀反代 |
|
|
128
131
|
| 日志 | PM2 落盘 + 历史分页(`beforeLine` 上滚、每页最多 100 行)+ SSE(含回放行与心跳) |
|
|
@@ -171,6 +174,7 @@ await fastify.listen({ port: 3000 });
|
|
|
171
174
|
| `package.json` | 根包描述 |
|
|
172
175
|
| `server/package.json` | 服务端依赖 |
|
|
173
176
|
| `server/index.js` | PM2 入口 |
|
|
177
|
+
| `server/env.schema.json` | 环境变量声明(必需);规范见 API 文档「环境变量 schema」 |
|
|
174
178
|
| `build/` | 已构建前端;含 `index.html` / `entry.html` / `entry-prod.html` 之一 |
|
|
175
179
|
| `server/sql/*.sql` | 可选;存在则 `hasMigration=true` |
|
|
176
180
|
|
|
@@ -286,11 +290,52 @@ await fastify.listen({ port: 3000 });
|
|
|
286
290
|
| 参数名 | 类型 | 必填 | 说明 |
|
|
287
291
|
|--------|------|------|------|
|
|
288
292
|
| name | string | 是 | 应用 slug |
|
|
289
|
-
| env | object | 是 | patch:`null` 删键;secret 键值为 `********`
|
|
293
|
+
| env | object | 是 | patch:`null` 删键;secret 键值为 `********` 时保持原值;`PORT` 与 `systemEnvKeys` 中的键忽略 |
|
|
290
294
|
| secretEnvKeys | array | 否 | 新增的显式密钥键名,与原列表合并写入 `options.secretEnvKeys`(仅保留仍存在于 env 的键)。密钥类型不可撤销,只有删除该变量才会移出列表 |
|
|
291
295
|
|
|
292
296
|
> **关键设计**:密钥判定 = 键名匹配 `secretEnvKeyPattern` **或** 出现在 `options.secretEnvKeys`。响应顶层 `secretEnvKeys` 为二者并集,便于管理端把普通变量标成密钥而不改键名。
|
|
293
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
|
+
|
|
294
339
|
##### 数据运维与表生命周期
|
|
295
340
|
|
|
296
341
|
解析后的应用库:应用 `env` 的 `DB_*` 优先,否则注入 `defaultAppDb`。共享库(未自配 `DB_*`)仅能操作 `options.ownedTables`。
|
|
@@ -337,7 +382,7 @@ await fastify.listen({ port: 3000 });
|
|
|
337
382
|
| version | string | 是 | 版本号;同应用内唯一 |
|
|
338
383
|
| label | string | 否 | 版本说明 |
|
|
339
384
|
|
|
340
|
-
成功返回 `appVersion` 记录(含 `artifactPath`、`hasMigration`、`fileId`、`sqlFiles` 等)。包中 `server/{sqlPath}/*.sql`
|
|
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;同版本号仅残留软删除记录时会先彻底清除再写入。
|
|
341
386
|
|
|
342
387
|
##### GET `{prefix}/app/version/list`
|
|
343
388
|
|
|
@@ -379,7 +424,7 @@ await fastify.listen({ port: 3000 });
|
|
|
379
424
|
|
|
380
425
|
##### POST `{prefix}/app/deploy`
|
|
381
426
|
|
|
382
|
-
选择版本部署。版本目录不在磁盘时先经 `resolveVersionFile` 取回 zip 解压并 `npm install`(耗时,取不回返回 400 且不改应用状态),再以数据库中的 SQL 覆盖目录内 `.sql
|
|
427
|
+
选择版本部署。版本目录不在磁盘时先经 `resolveVersionFile` 取回 zip 解压并 `npm install`(耗时,取不回返回 400 且不改应用状态),再以数据库中的 SQL 覆盖目录内 `.sql`;接着按版本的 `env.schema.json` 校验最终环境变量(含注入的变量),版本没有 schema 或变量不合规时返回 400,且不切换版本、不执行迁移、不启动进程;随后立即返回 `deploying`,后台做健康检查。进程启动后删除该应用其它有 `fileId` 的版本目录(只保留当前版本)。
|
|
383
428
|
|
|
384
429
|
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
|
385
430
|
|--------|------|------|--------|------|
|
|
@@ -426,6 +471,7 @@ SSE 部署进度。连接时先推送 `snapshot`(该应用最近一次部署
|
|
|
426
471
|
| step | 说明 | `detail`(`status=running` 时) |
|
|
427
472
|
|------|------|------|
|
|
428
473
|
| `package` | 准备版本目录 | `reuse` 目录已在磁盘 / `download` 取回 zip / `extract` 解压 / `install` 安装依赖 |
|
|
474
|
+
| `env` | 按 `env.schema.json` 校验环境变量;失败时 `message` 列出每个不合规的键与原因 | - |
|
|
429
475
|
| `migrate` | 预跑 SQL 迁移;未开启 `runMigration`、`migrateBeforeStart` 或版本无脚本时为 `skipped` | - |
|
|
430
476
|
| `start` | PM2 拉起进程 | - |
|
|
431
477
|
| `ready` | 健康检查;通过为 `done`,否则 `error`(`message` 为原因,如 `health check timeout`) | - |
|
|
@@ -434,7 +480,7 @@ SSE 部署进度。连接时先推送 `snapshot`(该应用最近一次部署
|
|
|
434
480
|
|
|
435
481
|
```text
|
|
436
482
|
event: snapshot
|
|
437
|
-
data: {"steps":["package","migrate","start","ready"],"events":[]}
|
|
483
|
+
data: {"steps":["package","env","migrate","start","ready"],"events":[]}
|
|
438
484
|
|
|
439
485
|
event: progress
|
|
440
486
|
data: {"progressId":"p-1","step":"package","status":"running","detail":"download","message":null,"at":"2026-10-10T10:00:00.000Z"}
|
|
@@ -509,9 +555,9 @@ data: {"progressId":"p-1","step":"ready","status":"done","detail":null,"message"
|
|
|
509
555
|
|
|
510
556
|
| 动作 | 行为摘要 |
|
|
511
557
|
|------|----------|
|
|
512
|
-
| start | 需已有 `currentVersionId
|
|
558
|
+
| start | 需已有 `currentVersionId`;先按 `env.schema.json` 校验环境变量,不通过返回 400 且不改状态;拉起进程并后台健康检查 |
|
|
513
559
|
| stop | PM2 stop,状态 `stopped` |
|
|
514
|
-
| restart |
|
|
560
|
+
| restart | 先校验环境变量(不通过返回 400,正在运行的进程不受影响),再停止并重新 startProcess |
|
|
515
561
|
| remove | 删 PM2 进程、版本记录、落盘目录与 App 行 |
|
|
516
562
|
|
|
517
563
|
##### GET `{prefix}/app/logs`
|
|
@@ -686,6 +732,9 @@ data: {"name":"demo","ts":1791456608533,...}
|
|
|
686
732
|
| `listVersions({ name, perPage, currentPage })` | 版本列表 |
|
|
687
733
|
| `deploy({ name, versionId, version, runMigration, migrations?, progressId? })` | 部署;版本目录缺失时先从宿主文件重建,启动后清理其它版本目录;立即 `deploying`,后台 `syncRealStatus`;各阶段进度写入 `getDeployProgress()` |
|
|
688
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`,环境变量声明中不展示 |
|
|
689
738
|
| `start` / `stop` / `restart` | 生命周期;`start`/`restart` 立即 `deploying`,后台按 PM2+health 写回真实状态 |
|
|
690
739
|
| `syncStatus({ name, recoverIfMissing? })` | 同步单个应用真实状态并返回公开对象 |
|
|
691
740
|
| `syncAllStatuses({ recoverIfMissing?, excludeIds? })` | 批量同步(父应用启动 reconcile 使用);`excludeIds` 中的应用跳过 |
|
|
@@ -777,6 +826,7 @@ data: {"name":"demo","ts":1791456608533,...}
|
|
|
777
826
|
| migrationPath | string | 相对 server 的 sql 目录,默认 `sql` |
|
|
778
827
|
| fileId | string / null | 宿主文件 id(经 `fileId` 上传时写入);版本目录据此按需重建,非当前版本不常驻磁盘 |
|
|
779
828
|
| migrationFiles | json / null | SQL 迁移脚本 `[{ name, content, updatedAt }]`,接口不返回(列表只给 `sqlFiles` 文件名) |
|
|
829
|
+
| envSchema | json / null | `server/env.schema.json` 内容,接口不返回(列表只给 `hasEnvSchema`);老版本为空时从版本目录回填 |
|
|
780
830
|
|
|
781
831
|
唯一约束:`(app_name, version)`。
|
|
782
832
|
|
|
@@ -840,9 +890,31 @@ rename 为 {stream}-{起始时间}.log ──→ pm2 reloadLogs ──→ gzip
|
|
|
840
890
|
| 非 Buffer / 超 `maxZipSize` / 超 `maxZipEntries` | 抛错 |
|
|
841
891
|
| 条目含 `..` 或逃出目标目录 | Zip Slip,抛错 |
|
|
842
892
|
| 缺根/server package、server/index、build 入口 html | `invalid package` |
|
|
893
|
+
| 缺 `server/env.schema.json`、JSON 非法或不符合规范 | `invalid package`(从宿主文件还原老版本目录时不检查,由启动前校验提示) |
|
|
843
894
|
|
|
844
895
|
部署时会将根 `build/` 复制到 `server/build/`,再对 `server/build` 做 entryHtml 注入。
|
|
845
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
|
+
|
|
846
918
|
##### 环境合并顺序
|
|
847
919
|
|
|
848
920
|
```
|
package/libs/controllers/app.js
CHANGED
|
@@ -114,6 +114,25 @@ module.exports = fp(async (fastify, options) => {
|
|
|
114
114
|
async request => services.app.saveEnv(request.body)
|
|
115
115
|
);
|
|
116
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
|
+
|
|
117
136
|
fastify.post(
|
|
118
137
|
`${options.prefix}/app/version/upload`,
|
|
119
138
|
{
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const fp = require('fastify-plugin');
|
|
2
|
+
const http = require('node:http');
|
|
2
3
|
const { Readable } = require('node:stream');
|
|
4
|
+
const httpProxy = require('http-proxy');
|
|
3
5
|
const { rewriteLocation, rewriteSetCookiePath, shouldRewriteBody, rewriteBody, tagEtag, restoreConditionalHeaders } = require('../utils/pathRewrite');
|
|
4
6
|
const { wantsHtml, renderUnavailablePage, getStatusText } = require('../utils/unavailablePage');
|
|
5
7
|
|
|
@@ -7,17 +9,20 @@ module.exports = fp(async (fastify, options) => {
|
|
|
7
9
|
const ns = options.name;
|
|
8
10
|
const pathPrefix = options.pathPrefix.replace(/\/$/, '') || '/app';
|
|
9
11
|
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
targetPath = `/${targetPath}`;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
12
|
+
const pathMatcher = new RegExp(`^${pathPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/([^/?#]+)`);
|
|
13
|
+
|
|
14
|
+
const stripMountPrefix = (url, appName) => {
|
|
15
|
+
const mount = `${pathPrefix}/${appName}`;
|
|
16
|
+
if (url === mount || url.startsWith(mount + '/') || url.startsWith(mount + '?')) {
|
|
17
|
+
const rest = url.slice(mount.length) || '/';
|
|
18
|
+
return rest.startsWith('/') ? rest : `/${rest}`;
|
|
20
19
|
}
|
|
20
|
+
return url;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const proxyToApp = async (request, reply, app, { stripPrefix } = {}) => {
|
|
24
|
+
const rawUrl = request.raw.url || '/';
|
|
25
|
+
const targetPath = stripPrefix ? stripMountPrefix(rawUrl, app.name) : rawUrl;
|
|
21
26
|
|
|
22
27
|
const dest = `http://127.0.0.1:${app.port}${targetPath}`;
|
|
23
28
|
const mountPrefix = `${pathPrefix}/${app.name}`;
|
|
@@ -128,7 +133,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
128
133
|
return byHost.status === 'running' ? proxyToApp(request, reply, byHost, { stripPrefix: false }) : replyUnavailable(request, reply, byHost);
|
|
129
134
|
}
|
|
130
135
|
|
|
131
|
-
const match = url.match(
|
|
136
|
+
const match = url.match(pathMatcher);
|
|
132
137
|
if (match) {
|
|
133
138
|
const appName = decodeName(match[1]);
|
|
134
139
|
const app = await services.app.findByPathName(appName, { running: false });
|
|
@@ -138,4 +143,68 @@ module.exports = fp(async (fastify, options) => {
|
|
|
138
143
|
return replyUnavailable(request, reply, { name: appName, status: 'missing' });
|
|
139
144
|
}
|
|
140
145
|
});
|
|
146
|
+
|
|
147
|
+
const wsProxy = httpProxy.createProxyServer({ changeOrigin: true });
|
|
148
|
+
|
|
149
|
+
const rejectUpgrade = (socket, statusCode) => {
|
|
150
|
+
if (!socket.writable) {
|
|
151
|
+
return socket.destroy();
|
|
152
|
+
}
|
|
153
|
+
socket.end(`HTTP/1.1 ${statusCode} ${http.STATUS_CODES[statusCode]}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const resolveUpgradeTarget = async req => {
|
|
157
|
+
const url = req.url || '/';
|
|
158
|
+
if (url.startsWith(options.prefix)) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const services = fastify[ns]?.services;
|
|
162
|
+
if (!services?.app) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
const byHost = await services.app.findByHost(req.headers.host, { running: false });
|
|
166
|
+
if (byHost) {
|
|
167
|
+
return { app: byHost, targetPath: url };
|
|
168
|
+
}
|
|
169
|
+
const match = url.match(pathMatcher);
|
|
170
|
+
if (!match) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
const appName = decodeName(match[1]);
|
|
174
|
+
const app = await services.app.findByPathName(appName, { running: false });
|
|
175
|
+
return { app: app || { name: appName, status: 'missing' }, targetPath: stripMountPrefix(url, appName) };
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// reply-from only speaks plain HTTP, so WebSocket handshakes under an app go through http-proxy instead.
|
|
179
|
+
const onUpgrade = async (req, socket, head) => {
|
|
180
|
+
socket.on('error', () => socket.destroy());
|
|
181
|
+
let target;
|
|
182
|
+
try {
|
|
183
|
+
target = await resolveUpgradeTarget(req);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
fastify.log.error(error);
|
|
186
|
+
return rejectUpgrade(socket, 502);
|
|
187
|
+
}
|
|
188
|
+
if (!target) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// Node servers allow half-open sockets; once our side has finished writing (tunnel ended or upgrade declined) drop the connection.
|
|
192
|
+
socket.once('finish', () => socket.destroy());
|
|
193
|
+
const { app, targetPath } = target;
|
|
194
|
+
if (app.status !== 'running') {
|
|
195
|
+
return rejectUpgrade(socket, app.status === 'missing' ? 404 : 503);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
req.url = targetPath;
|
|
199
|
+
wsProxy.ws(req, socket, head, { target: `http://127.0.0.1:${app.port}` }, error => {
|
|
200
|
+
fastify.log.warn({ err: error, app: app.name }, 'websocket upgrade proxy failed');
|
|
201
|
+
rejectUpgrade(socket, 502);
|
|
202
|
+
});
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
fastify.server.on('upgrade', onUpgrade);
|
|
206
|
+
fastify.addHook('onClose', async () => {
|
|
207
|
+
fastify.server.removeListener('upgrade', onUpgrade);
|
|
208
|
+
wsProxy.close();
|
|
209
|
+
});
|
|
141
210
|
});
|
|
@@ -43,6 +43,11 @@ module.exports = ({ DataTypes }) => {
|
|
|
43
43
|
type: DataTypes.JSON,
|
|
44
44
|
allowNull: true,
|
|
45
45
|
comment: 'SQL 迁移文件 [{ name, content, updatedAt }];版本目录可随时删除,部署时写回磁盘'
|
|
46
|
+
},
|
|
47
|
+
envSchema: {
|
|
48
|
+
type: DataTypes.JSON,
|
|
49
|
+
allowNull: true,
|
|
50
|
+
comment: 'server/env.schema.json 内容;启动前按它校验环境变量'
|
|
46
51
|
}
|
|
47
52
|
},
|
|
48
53
|
options: {
|
package/libs/services/app.js
CHANGED
|
@@ -3,7 +3,22 @@ const path = require('node:path');
|
|
|
3
3
|
const crypto = require('node:crypto');
|
|
4
4
|
const fs = require('fs-extra');
|
|
5
5
|
const portPool = require('../utils/portPool');
|
|
6
|
-
const {
|
|
6
|
+
const {
|
|
7
|
+
mergeEnv,
|
|
8
|
+
maskEnvForResponse,
|
|
9
|
+
applyEnvPatch,
|
|
10
|
+
mergePm2Config,
|
|
11
|
+
normalizeSecretKeys,
|
|
12
|
+
collectSecretKeys,
|
|
13
|
+
isSystemEnvKey,
|
|
14
|
+
isSecretKey,
|
|
15
|
+
omitSystemEnv,
|
|
16
|
+
SECRET_MASK,
|
|
17
|
+
SYSTEM_ENV_KEYS,
|
|
18
|
+
DB_CONNECTION_KEYS,
|
|
19
|
+
DB_DEDICATED_KEY
|
|
20
|
+
} = require('../utils/env');
|
|
21
|
+
const { readEnvSchema, validateEnv, ENV_SCHEMA_FILE } = require('../utils/envSchema');
|
|
7
22
|
const { prepareVersionArtifact } = require('../utils/version');
|
|
8
23
|
const { injectEntryHtml } = require('../utils/entryInject');
|
|
9
24
|
const { ensureLogFiles, readLogTail, readLastLines, appendLog, countLines } = require('../utils/logFiles');
|
|
@@ -198,7 +213,9 @@ module.exports = fp(async (fastify, options) => {
|
|
|
198
213
|
const app = await getByName(name);
|
|
199
214
|
// 密钥类型不可撤销,只随变量删除而移除
|
|
200
215
|
const secretEnvKeys = normalizeSecretKeys([...resolveSecretEnvKeys(app), ...(nextSecretKeys || [])]);
|
|
201
|
-
|
|
216
|
+
// PORT 由端口池分配,启动时总会覆盖,不接受外部写入
|
|
217
|
+
const { PORT, ...envPatch } = env || {};
|
|
218
|
+
const nextEnv = applyEnvPatch(app.env || {}, envPatch, Object.assign({}, options, { secretEnvKeys }));
|
|
202
219
|
const cleanedSecrets = secretEnvKeys.filter(key => !isSystemEnvKey(key, options.systemEnvKeys) && Object.prototype.hasOwnProperty.call(nextEnv, key));
|
|
203
220
|
await app.update({
|
|
204
221
|
env: nextEnv,
|
|
@@ -279,13 +296,31 @@ module.exports = fp(async (fastify, options) => {
|
|
|
279
296
|
};
|
|
280
297
|
|
|
281
298
|
const toPublicVersion = (row, files = row.migrationFiles) => {
|
|
282
|
-
const { migrationFiles, ...json } = row.toJSON();
|
|
299
|
+
const { migrationFiles, envSchema, ...json } = row.toJSON();
|
|
283
300
|
if (Array.isArray(files) && files.length) {
|
|
284
301
|
json.sqlFiles = files.map(item => item.name);
|
|
285
302
|
}
|
|
303
|
+
json.hasEnvSchema = !!envSchema;
|
|
286
304
|
return json;
|
|
287
305
|
};
|
|
288
306
|
|
|
307
|
+
/** schema 以数据库为准;旧版本行(envSchema 为空)且版本目录在磁盘时从目录回填 */
|
|
308
|
+
const getVersionEnvSchema = async ver => {
|
|
309
|
+
if (ver.envSchema) {
|
|
310
|
+
return ver.envSchema;
|
|
311
|
+
}
|
|
312
|
+
if (!(await hasArtifact(ver))) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
const schema = await readEnvSchema(path.join(ver.artifactPath, 'server'));
|
|
317
|
+
await ver.update({ envSchema: schema });
|
|
318
|
+
return schema;
|
|
319
|
+
} catch (e) {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
289
324
|
const uploadVersion = async ({ name, version, label, zipBuffer, fileId }) => {
|
|
290
325
|
if (!version) {
|
|
291
326
|
httpError(400, 'version is required');
|
|
@@ -338,7 +373,8 @@ module.exports = fp(async (fastify, options) => {
|
|
|
338
373
|
hasMigration: migrationFiles.length > 0,
|
|
339
374
|
migrationPath: prepared.migrationPath,
|
|
340
375
|
fileId: fileId ? String(fileId) : null,
|
|
341
|
-
migrationFiles
|
|
376
|
+
migrationFiles,
|
|
377
|
+
envSchema: prepared.envSchema
|
|
342
378
|
},
|
|
343
379
|
{ transaction }
|
|
344
380
|
);
|
|
@@ -579,13 +615,9 @@ module.exports = fp(async (fastify, options) => {
|
|
|
579
615
|
return results.filter(Boolean);
|
|
580
616
|
};
|
|
581
617
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
if (!version) {
|
|
585
|
-
throw new Error('current version missing');
|
|
586
|
-
}
|
|
618
|
+
/** 子进程最终环境变量:宿主透传 → 托管库 → 应用变量 → PORT → resolveSystemEnv;顺带持久化表前缀与系统变量 */
|
|
619
|
+
const buildProcessEnv = async (app, version) => {
|
|
587
620
|
const serverDir = path.join(version.artifactPath, 'server');
|
|
588
|
-
const { outFile, errFile } = await ensureLogFiles(app.rootPath);
|
|
589
621
|
const env = mergeEnv({
|
|
590
622
|
passthroughEnvKeys: options.passthroughEnvKeys,
|
|
591
623
|
appEnv: app.env,
|
|
@@ -604,6 +636,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
604
636
|
|
|
605
637
|
if (typeof options.resolveSystemEnv === 'function') {
|
|
606
638
|
const systemEnv = await options.resolveSystemEnv({ app, version, serverDir });
|
|
639
|
+
const injectedKeys = [];
|
|
607
640
|
if (systemEnv && typeof systemEnv === 'object') {
|
|
608
641
|
const persisted = {};
|
|
609
642
|
for (const [key, value] of Object.entries(systemEnv)) {
|
|
@@ -611,6 +644,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
611
644
|
continue;
|
|
612
645
|
}
|
|
613
646
|
env[key] = String(value);
|
|
647
|
+
injectedKeys.push(key);
|
|
614
648
|
if ((options.systemEnvKeys || []).includes(key) && app.env?.[key] !== env[key]) {
|
|
615
649
|
persisted[key] = env[key];
|
|
616
650
|
}
|
|
@@ -620,7 +654,143 @@ module.exports = fp(async (fastify, options) => {
|
|
|
620
654
|
await app.reload();
|
|
621
655
|
}
|
|
622
656
|
}
|
|
657
|
+
// 记下本次由系统注入的键,供环境变量声明接口隐藏(如未配置 ORIGIN 时由宿主补上的值)
|
|
658
|
+
const recorded = app.options?.injectedEnvKeys || [];
|
|
659
|
+
if (recorded.length !== injectedKeys.length || recorded.some(key => !injectedKeys.includes(key))) {
|
|
660
|
+
await app.update({ options: Object.assign({}, app.options || {}, { injectedEnvKeys: injectedKeys }) });
|
|
661
|
+
await app.reload();
|
|
662
|
+
}
|
|
623
663
|
}
|
|
664
|
+
return env;
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
const envSchemaMissingError = () => {
|
|
668
|
+
const err = new Error(`该版本缺少 server/${ENV_SCHEMA_FILE},请补充后重新上传`);
|
|
669
|
+
err.envErrors = [];
|
|
670
|
+
return err;
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
/** 按版本的 env.schema.json 校验最终环境变量,不通过时抛错(err.envErrors 为 [{ key, message }]) */
|
|
674
|
+
const assertEnvValid = async (app, version, env) => {
|
|
675
|
+
const schema = await getVersionEnvSchema(version);
|
|
676
|
+
if (!schema) {
|
|
677
|
+
throw envSchemaMissingError();
|
|
678
|
+
}
|
|
679
|
+
const errors = validateEnv(schema, env);
|
|
680
|
+
if (errors.length) {
|
|
681
|
+
const injected = listInjectedEnvKeys(app);
|
|
682
|
+
const detail = errors.map(({ key, message }) => `${key}${injected.has(key) ? '(应用中心注入)' : ''} ${message}`).join(';');
|
|
683
|
+
const err = new Error(`环境变量不符合 ${ENV_SCHEMA_FILE}:${detail}`);
|
|
684
|
+
err.envErrors = errors;
|
|
685
|
+
throw err;
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
/** 应用中心自动注入或在其它页签维护的变量名:环境变量管理中不展示 */
|
|
690
|
+
const listInjectedEnvKeys = app =>
|
|
691
|
+
new Set([
|
|
692
|
+
'PORT',
|
|
693
|
+
...SYSTEM_ENV_KEYS,
|
|
694
|
+
...(options.systemEnvKeys || []),
|
|
695
|
+
...(options.passthroughEnvKeys || []),
|
|
696
|
+
// 托管库连接由宿主注入,自有库在数据页签配置
|
|
697
|
+
...DB_CONNECTION_KEYS,
|
|
698
|
+
DB_DEDICATED_KEY,
|
|
699
|
+
...(app.options?.injectedEnvKeys || [])
|
|
700
|
+
]);
|
|
701
|
+
|
|
702
|
+
const resolveDeclarationVersion = async (app, versionId) => {
|
|
703
|
+
if (versionId) {
|
|
704
|
+
const ver = await models.appVersion.findByPk(versionId);
|
|
705
|
+
if (!ver || ver.appName !== app.name) {
|
|
706
|
+
httpError(404, 'version not found');
|
|
707
|
+
}
|
|
708
|
+
return ver;
|
|
709
|
+
}
|
|
710
|
+
if (app.currentVersionId) {
|
|
711
|
+
const ver = await models.appVersion.findByPk(app.currentVersionId);
|
|
712
|
+
if (ver) {
|
|
713
|
+
return ver;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
return models.appVersion.findOne({ where: { appName: app.name }, order: [['createdAt', 'DESC']] });
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* 环境变量声明:declared 为 schema 中需要用户填写的变量,others 为已配置但未声明的变量;
|
|
721
|
+
* 两者都不含应用中心注入的变量,密钥值掩码
|
|
722
|
+
*/
|
|
723
|
+
const getEnvDeclaration = async ({ name, versionId } = {}) => {
|
|
724
|
+
const app = await getByName(name);
|
|
725
|
+
const ver = await resolveDeclarationVersion(app, versionId);
|
|
726
|
+
const schema = ver ? await getVersionEnvSchema(ver) : null;
|
|
727
|
+
const injected = listInjectedEnvKeys(app);
|
|
728
|
+
const appEnv = omitSystemEnv(app.env || {}, options.systemEnvKeys);
|
|
729
|
+
const secretOpts = Object.assign({}, options, { secretEnvKeys: resolveSecretEnvKeys(app) });
|
|
730
|
+
const isSecret = (key, prop) => !!prop?.writeOnly || isSecretKey(key, options.secretEnvKeyPattern, secretOpts.secretEnvKeys);
|
|
731
|
+
const maskValue = (key, prop) => {
|
|
732
|
+
const value = appEnv[key];
|
|
733
|
+
if (value == null || value === '') {
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
return isSecret(key, prop) ? SECRET_MASK : value;
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
const properties = schema?.properties || {};
|
|
740
|
+
const required = new Set(schema?.required || []);
|
|
741
|
+
const errorMap = new Map();
|
|
742
|
+
if (schema) {
|
|
743
|
+
const previewEnv = mergeEnv({
|
|
744
|
+
passthroughEnvKeys: options.passthroughEnvKeys,
|
|
745
|
+
appEnv: app.env,
|
|
746
|
+
port: app.port,
|
|
747
|
+
defaultAppDb: options.defaultAppDb,
|
|
748
|
+
appName: app.name
|
|
749
|
+
});
|
|
750
|
+
validateEnv(schema, previewEnv)
|
|
751
|
+
.filter(item => !injected.has(item.key))
|
|
752
|
+
.forEach(item => errorMap.set(item.key, item.message));
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const declared = Object.entries(properties)
|
|
756
|
+
.filter(([key]) => !injected.has(key))
|
|
757
|
+
.map(([key, prop]) => ({
|
|
758
|
+
key,
|
|
759
|
+
type: prop?.type || null,
|
|
760
|
+
required: required.has(key),
|
|
761
|
+
default: prop?.default === undefined ? null : prop.default,
|
|
762
|
+
description: prop?.description || prop?.title || null,
|
|
763
|
+
enum: Array.isArray(prop?.enum) ? prop.enum : null,
|
|
764
|
+
secret: isSecret(key, prop),
|
|
765
|
+
configured: appEnv[key] != null && appEnv[key] !== '',
|
|
766
|
+
value: maskValue(key, prop),
|
|
767
|
+
error: errorMap.get(key) || null
|
|
768
|
+
}));
|
|
769
|
+
|
|
770
|
+
const others = Object.keys(appEnv)
|
|
771
|
+
.filter(key => !injected.has(key) && !Object.prototype.hasOwnProperty.call(properties, key))
|
|
772
|
+
.map(key => ({ key, secret: isSecret(key), value: maskValue(key) }));
|
|
773
|
+
|
|
774
|
+
return {
|
|
775
|
+
name: app.name,
|
|
776
|
+
versionId: ver ? String(ver.id) : null,
|
|
777
|
+
version: ver ? ver.version : null,
|
|
778
|
+
hasSchema: !!schema,
|
|
779
|
+
valid: !!schema && errorMap.size === 0,
|
|
780
|
+
declared,
|
|
781
|
+
others
|
|
782
|
+
};
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
const startProcess = async (app, { env: preparedEnv } = {}) => {
|
|
786
|
+
const version = await models.appVersion.findByPk(app.currentVersionId);
|
|
787
|
+
if (!version) {
|
|
788
|
+
throw new Error('current version missing');
|
|
789
|
+
}
|
|
790
|
+
const serverDir = path.join(version.artifactPath, 'server');
|
|
791
|
+
const env = preparedEnv || (await buildProcessEnv(app, version));
|
|
792
|
+
await assertEnvValid(app, version, env);
|
|
793
|
+
const { outFile, errFile } = await ensureLogFiles(app.rootPath);
|
|
624
794
|
|
|
625
795
|
const pathBase = mountPrefix(app.name);
|
|
626
796
|
// Domain-first: inject `/` so custom Host SPA works; path mode then relies on gateway rewrite fallback.
|
|
@@ -688,6 +858,8 @@ module.exports = fp(async (fastify, options) => {
|
|
|
688
858
|
maxZipEntries: options.maxZipEntries,
|
|
689
859
|
npmInstallTimeoutMs: options.npmInstallTimeoutMs,
|
|
690
860
|
sqlPath: options.sqlPath,
|
|
861
|
+
// 老版本可能没有 env.schema.json:照常还原目录,由启动前的环境变量校验给出提示
|
|
862
|
+
requireEnvSchema: false,
|
|
691
863
|
onProgress
|
|
692
864
|
});
|
|
693
865
|
await fs.move(stagingPath, ver.artifactPath, { overwrite: true });
|
|
@@ -777,6 +949,19 @@ module.exports = fp(async (fastify, options) => {
|
|
|
777
949
|
}
|
|
778
950
|
progress.report(step, 'done');
|
|
779
951
|
|
|
952
|
+
// 变量不合规时不切换版本、不执行迁移、不启动进程
|
|
953
|
+
step = 'env';
|
|
954
|
+
progress.report(step, 'running');
|
|
955
|
+
let processEnv;
|
|
956
|
+
try {
|
|
957
|
+
processEnv = await buildProcessEnv(app, ver);
|
|
958
|
+
await assertEnvValid(app, ver, processEnv);
|
|
959
|
+
} catch (e) {
|
|
960
|
+
progress.report(step, 'error', { message: e.message });
|
|
961
|
+
httpError(400, e.message);
|
|
962
|
+
}
|
|
963
|
+
progress.report(step, 'done');
|
|
964
|
+
|
|
780
965
|
await app.update({
|
|
781
966
|
status: 'deploying',
|
|
782
967
|
currentVersionId: String(ver.id),
|
|
@@ -830,13 +1015,13 @@ module.exports = fp(async (fastify, options) => {
|
|
|
830
1015
|
|
|
831
1016
|
if (runMigration === false) {
|
|
832
1017
|
// child may honor RUN_SQL_ON_SYNC
|
|
833
|
-
|
|
1018
|
+
processEnv.RUN_SQL_ON_SYNC = 'false';
|
|
834
1019
|
}
|
|
835
1020
|
|
|
836
1021
|
step = 'start';
|
|
837
1022
|
progress.report(step, 'running');
|
|
838
1023
|
const beforeForReady = await snapshotTablesSafe(app);
|
|
839
|
-
await startProcess(app);
|
|
1024
|
+
await startProcess(app, { env: processEnv });
|
|
840
1025
|
progress.report(step, 'done');
|
|
841
1026
|
step = 'ready';
|
|
842
1027
|
progress.report(step, 'running');
|
|
@@ -872,15 +1057,28 @@ module.exports = fp(async (fastify, options) => {
|
|
|
872
1057
|
return toPublicApp(app);
|
|
873
1058
|
};
|
|
874
1059
|
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
1060
|
+
/** 启动 / 重启前先按 schema 校验,不通过直接报错,不改动正在运行的进程与状态 */
|
|
1061
|
+
const prepareStartEnv = async app => {
|
|
1062
|
+
const version = app.currentVersionId ? await models.appVersion.findByPk(app.currentVersionId) : null;
|
|
1063
|
+
if (!version) {
|
|
878
1064
|
httpError(400, 'no version deployed');
|
|
879
1065
|
}
|
|
1066
|
+
const env = await buildProcessEnv(app, version);
|
|
1067
|
+
try {
|
|
1068
|
+
await assertEnvValid(app, version, env);
|
|
1069
|
+
} catch (e) {
|
|
1070
|
+
httpError(400, e.message);
|
|
1071
|
+
}
|
|
1072
|
+
return env;
|
|
1073
|
+
};
|
|
1074
|
+
|
|
1075
|
+
const start = async ({ name }) => {
|
|
1076
|
+
const app = await getByName(name);
|
|
1077
|
+
const env = await prepareStartEnv(app);
|
|
880
1078
|
await app.update({ status: 'deploying', message: null });
|
|
881
1079
|
try {
|
|
882
1080
|
const beforeTables = await snapshotTablesSafe(app);
|
|
883
|
-
await startProcess(app);
|
|
1081
|
+
await startProcess(app, { env });
|
|
884
1082
|
scheduleStatusSync(app.id, { beforeTables });
|
|
885
1083
|
return toPublicApp(await getByName(name));
|
|
886
1084
|
} catch (e) {
|
|
@@ -891,6 +1089,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
891
1089
|
|
|
892
1090
|
const restart = async ({ name }) => {
|
|
893
1091
|
const app = await getByName(name);
|
|
1092
|
+
const env = await prepareStartEnv(app);
|
|
894
1093
|
await app.update({ status: 'deploying', message: null });
|
|
895
1094
|
try {
|
|
896
1095
|
const beforeTables = await snapshotTablesSafe(app);
|
|
@@ -900,7 +1099,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
900
1099
|
} catch (e) {
|
|
901
1100
|
// ignore if not running
|
|
902
1101
|
}
|
|
903
|
-
await startProcess(app);
|
|
1102
|
+
await startProcess(app, { env });
|
|
904
1103
|
scheduleStatusSync(app.id, { beforeTables });
|
|
905
1104
|
return toPublicApp(await getByName(name));
|
|
906
1105
|
} catch (e) {
|
|
@@ -1100,6 +1299,9 @@ module.exports = fp(async (fastify, options) => {
|
|
|
1100
1299
|
load,
|
|
1101
1300
|
getLoadStore,
|
|
1102
1301
|
getDeployProgress: () => deployProgress,
|
|
1302
|
+
waitDeployProgress: ({ name, progressId, timeoutMs }) => deployProgress.wait(name, progressId, { timeoutMs }),
|
|
1303
|
+
getEnvDeclaration,
|
|
1304
|
+
listInjectedEnvKeys,
|
|
1103
1305
|
toPublicApp,
|
|
1104
1306
|
mountPrefix,
|
|
1105
1307
|
findByHost,
|
|
@@ -42,7 +42,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
42
42
|
const queryInterface = model.sequelize.getQueryInterface();
|
|
43
43
|
const table = model.getTableName();
|
|
44
44
|
const columns = await queryInterface.describeTable(table);
|
|
45
|
-
for (const name of ['fileId', 'migrationFiles']) {
|
|
45
|
+
for (const name of ['fileId', 'migrationFiles', 'envSchema']) {
|
|
46
46
|
const attr = model.rawAttributes[name];
|
|
47
47
|
if (!columns[attr.field]) {
|
|
48
48
|
await queryInterface.addColumn(table, attr.field, { type: attr.type, allowNull: true, comment: attr.comment });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const { EventEmitter } = require('node:events');
|
|
2
2
|
|
|
3
|
-
/** 部署阶段,按顺序推进:准备部署包 → SQL 迁移 → 启动进程 → 等待就绪 */
|
|
4
|
-
const DEPLOY_STEPS = ['package', 'migrate', 'start', 'ready'];
|
|
3
|
+
/** 部署阶段,按顺序推进:准备部署包 → 校验环境变量 → SQL 迁移 → 启动进程 → 等待就绪 */
|
|
4
|
+
const DEPLOY_STEPS = ['package', 'env', 'migrate', 'start', 'ready'];
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* 每个应用只保留最近一次部署的进度事件,供 SSE 订阅与断线重连回放。
|
|
@@ -28,7 +28,36 @@ const createDeployProgress = () => {
|
|
|
28
28
|
|
|
29
29
|
const clear = name => latest.delete(name);
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
const isFinished = event => event.status === 'error' || (event.step === 'ready' && event.status !== 'running');
|
|
32
|
+
|
|
33
|
+
/** 等到该次部署出错或就绪阶段结束(或超时);返回 { progressId, ok, timedOut, events } */
|
|
34
|
+
const wait = (name, progressId, { timeoutMs = 120000 } = {}) =>
|
|
35
|
+
new Promise(resolve => {
|
|
36
|
+
const collect = () => {
|
|
37
|
+
const state = latest.get(name);
|
|
38
|
+
return state && state.progressId === progressId ? state.events.slice() : [];
|
|
39
|
+
};
|
|
40
|
+
let timer = null;
|
|
41
|
+
const listener = event => {
|
|
42
|
+
if (event.progressId === progressId && isFinished(event)) {
|
|
43
|
+
finish(false);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const finish = timedOut => {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
emitter.off(`deploy:${name}`, listener);
|
|
49
|
+
const events = collect();
|
|
50
|
+
resolve({ progressId, ok: !timedOut && !events.some(event => event.status === 'error'), timedOut, events });
|
|
51
|
+
};
|
|
52
|
+
if (collect().some(isFinished)) {
|
|
53
|
+
finish(false);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
emitter.on(`deploy:${name}`, listener);
|
|
57
|
+
timer = setTimeout(() => finish(true), timeoutMs);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return { emitter, begin, snapshot, clear, wait };
|
|
32
61
|
};
|
|
33
62
|
|
|
34
63
|
module.exports = {
|
package/libs/utils/env.js
CHANGED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const path = require('node:path');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const Ajv = require('ajv');
|
|
4
|
+
|
|
5
|
+
const ENV_SCHEMA_FILE = 'env.schema.json';
|
|
6
|
+
|
|
7
|
+
// 与 @fastify/env(env-schema)一致:类型转换、填充默认值,separator 为其内置关键字
|
|
8
|
+
const createAjv = () => {
|
|
9
|
+
const ajv = new Ajv({ allErrors: true, coerceTypes: true, useDefaults: true, allowUnionTypes: true });
|
|
10
|
+
ajv.addKeyword('separator');
|
|
11
|
+
return ajv;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** 检查 schema 是否符合规范:顶层 object、有 properties、required 中的键都已声明、能被 ajv 编译 */
|
|
15
|
+
const checkEnvSchema = schema => {
|
|
16
|
+
const errors = [];
|
|
17
|
+
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
|
18
|
+
return ['env.schema.json must be a JSON object'];
|
|
19
|
+
}
|
|
20
|
+
if (schema.type !== 'object') {
|
|
21
|
+
errors.push('env.schema.json: "type" must be "object"');
|
|
22
|
+
}
|
|
23
|
+
if (!schema.properties || typeof schema.properties !== 'object' || Array.isArray(schema.properties)) {
|
|
24
|
+
errors.push('env.schema.json: "properties" must be an object');
|
|
25
|
+
}
|
|
26
|
+
if (schema.required !== undefined) {
|
|
27
|
+
if (!Array.isArray(schema.required) || schema.required.some(key => typeof key !== 'string')) {
|
|
28
|
+
errors.push('env.schema.json: "required" must be an array of strings');
|
|
29
|
+
} else if (schema.properties && typeof schema.properties === 'object') {
|
|
30
|
+
const undeclared = schema.required.filter(key => !Object.prototype.hasOwnProperty.call(schema.properties, key));
|
|
31
|
+
if (undeclared.length) {
|
|
32
|
+
errors.push(`env.schema.json: required keys not declared in properties: ${undeclared.join(', ')}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (!errors.length) {
|
|
37
|
+
try {
|
|
38
|
+
createAjv().compile(schema);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
errors.push(`env.schema.json: ${e.message}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return errors;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** 读取 server/env.schema.json;文件缺失、JSON 非法或不符合规范时抛错 */
|
|
47
|
+
const readEnvSchema = async serverDir => {
|
|
48
|
+
const file = path.join(serverDir, ENV_SCHEMA_FILE);
|
|
49
|
+
if (!(await fs.pathExists(file))) {
|
|
50
|
+
throw new Error(`missing server/${ENV_SCHEMA_FILE}`);
|
|
51
|
+
}
|
|
52
|
+
let schema;
|
|
53
|
+
try {
|
|
54
|
+
schema = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
55
|
+
} catch (e) {
|
|
56
|
+
throw new Error(`server/${ENV_SCHEMA_FILE} is not valid JSON: ${e.message}`);
|
|
57
|
+
}
|
|
58
|
+
const errors = checkEnvSchema(schema);
|
|
59
|
+
if (errors.length) {
|
|
60
|
+
throw new Error(errors.join('; '));
|
|
61
|
+
}
|
|
62
|
+
return schema;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const formatError = error => {
|
|
66
|
+
const { keyword, params = {} } = error;
|
|
67
|
+
if (keyword === 'required') {
|
|
68
|
+
return { key: params.missingProperty, message: '必填,未配置' };
|
|
69
|
+
}
|
|
70
|
+
const key = String(error.instancePath || '')
|
|
71
|
+
.replace(/^\//, '')
|
|
72
|
+
.split('/')[0];
|
|
73
|
+
if (keyword === 'type') {
|
|
74
|
+
return { key, message: `类型应为 ${params.type}` };
|
|
75
|
+
}
|
|
76
|
+
if (keyword === 'enum') {
|
|
77
|
+
return { key, message: `取值应为 ${(params.allowedValues || []).join(' / ')}` };
|
|
78
|
+
}
|
|
79
|
+
if (keyword === 'pattern') {
|
|
80
|
+
return { key, message: `格式应匹配 ${params.pattern}` };
|
|
81
|
+
}
|
|
82
|
+
return { key, message: error.message };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** 按 schema 校验环境变量;空字符串视为未配置。返回 [{ key, message }],空数组表示通过 */
|
|
86
|
+
const validateEnv = (schema, env = {}) => {
|
|
87
|
+
const data = {};
|
|
88
|
+
for (const [key, value] of Object.entries(env || {})) {
|
|
89
|
+
if (value != null && value !== '') {
|
|
90
|
+
data[key] = value;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const validate = createAjv().compile(schema);
|
|
94
|
+
if (validate(data)) {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
return (validate.errors || []).map(formatError);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
module.exports = {
|
|
101
|
+
ENV_SCHEMA_FILE,
|
|
102
|
+
checkEnvSchema,
|
|
103
|
+
readEnvSchema,
|
|
104
|
+
validateEnv
|
|
105
|
+
};
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
const path = require('node:path');
|
|
2
2
|
const fs = require('fs-extra');
|
|
3
|
+
const { readEnvSchema } = require('./envSchema');
|
|
3
4
|
|
|
4
5
|
const ENTRY_CANDIDATES = ['index.html', 'entry.html', 'entry-prod.html'];
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
/** requireEnvSchema=false 时缺少 / 非法的 env.schema.json 不算错误,envSchema 返回 null(用于从宿主文件还原老版本) */
|
|
8
|
+
const validatePackageRoot = async (rootDir, { requireEnvSchema = true } = {}) => {
|
|
7
9
|
const errors = [];
|
|
8
10
|
const rootPkg = path.join(rootDir, 'package.json');
|
|
9
11
|
const serverPkg = path.join(rootDir, 'server', 'package.json');
|
|
@@ -28,9 +30,19 @@ const validatePackageRoot = async rootDir => {
|
|
|
28
30
|
}
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
let envSchema = null;
|
|
34
|
+
try {
|
|
35
|
+
envSchema = await readEnvSchema(path.join(rootDir, 'server'));
|
|
36
|
+
} catch (e) {
|
|
37
|
+
if (requireEnvSchema) {
|
|
38
|
+
errors.push(e.message);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
return {
|
|
32
43
|
ok: errors.length === 0,
|
|
33
44
|
errors,
|
|
45
|
+
envSchema,
|
|
34
46
|
hasMigration: await hasSqlMigrations(path.join(rootDir, 'server', 'sql'))
|
|
35
47
|
};
|
|
36
48
|
};
|
package/libs/utils/version.js
CHANGED
|
@@ -34,14 +34,14 @@ const runNpmInstall = (cwd, timeoutMs) =>
|
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
/** install=false 时只解压校验,不装依赖(用于不常驻磁盘的版本上传) */
|
|
37
|
-
const prepareVersionArtifact = async ({ zipBuffer, artifactPath, maxZipSize, maxZipEntries, npmInstallTimeoutMs, sqlPath, install = true, onProgress }) => {
|
|
37
|
+
const prepareVersionArtifact = async ({ zipBuffer, artifactPath, maxZipSize, maxZipEntries, npmInstallTimeoutMs, sqlPath, install = true, requireEnvSchema = true, onProgress }) => {
|
|
38
38
|
await fs.emptyDir(artifactPath);
|
|
39
39
|
onProgress && onProgress('extract');
|
|
40
40
|
const extractDir = path.join(artifactPath, '_extract');
|
|
41
41
|
await extractZipSafe(zipBuffer, extractDir, { maxZipSize, maxZipEntries });
|
|
42
42
|
const packageRoot = await resolvePackageRoot(extractDir);
|
|
43
43
|
|
|
44
|
-
const validation = await validatePackageRoot(packageRoot);
|
|
44
|
+
const validation = await validatePackageRoot(packageRoot, { requireEnvSchema });
|
|
45
45
|
if (!validation.ok) {
|
|
46
46
|
await fs.remove(artifactPath);
|
|
47
47
|
throw new Error(`invalid package: ${validation.errors.join('; ')}`);
|
|
@@ -72,7 +72,8 @@ const prepareVersionArtifact = async ({ zipBuffer, artifactPath, maxZipSize, max
|
|
|
72
72
|
return {
|
|
73
73
|
hasMigration: sqlFiles.length > 0,
|
|
74
74
|
migrationPath: sqlPath || 'sql',
|
|
75
|
-
sqlFiles
|
|
75
|
+
sqlFiles,
|
|
76
|
+
envSchema: validation.envSchema
|
|
76
77
|
};
|
|
77
78
|
};
|
|
78
79
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kne/fastify-app-manager",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "Fastify 单机应用市场插件:版本上传、PM2 托管、路径/域名反代与日志 SSE",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -69,7 +69,9 @@
|
|
|
69
69
|
"@fastify/reply-from": "^12.6.5",
|
|
70
70
|
"@kne/ensure-slash": "^0.1.0",
|
|
71
71
|
"adm-zip": "^0.6.0",
|
|
72
|
+
"ajv": "^8.17.1",
|
|
72
73
|
"fs-extra": "^11.4.0",
|
|
74
|
+
"http-proxy": "^1.18.1",
|
|
73
75
|
"jsdom": "^29.1.1",
|
|
74
76
|
"lodash": "^4.18.1",
|
|
75
77
|
"pm2": "^7.0.4",
|