@kne/fastify-app-manager 0.1.1
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 +486 -0
- package/index.js +87 -0
- package/libs/controllers/app.js +274 -0
- package/libs/controllers/gateway.js +88 -0
- package/libs/models/app.js +88 -0
- package/libs/models/appVersion.js +43 -0
- package/libs/services/app.js +470 -0
- package/libs/services/bootstrap.js +102 -0
- package/libs/utils/entryInject.js +76 -0
- package/libs/utils/env.js +79 -0
- package/libs/utils/logFiles.js +52 -0
- package/libs/utils/logStream.js +39 -0
- package/libs/utils/migrate.js +70 -0
- package/libs/utils/pathRewrite.js +55 -0
- package/libs/utils/pm2.js +96 -0
- package/libs/utils/portPool.js +29 -0
- package/libs/utils/validatePackage.js +59 -0
- package/libs/utils/version.js +77 -0
- package/libs/utils/zip.js +69 -0
- package/package.json +78 -0
package/README.md
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
# fastify-app-manager
|
|
2
|
+
|
|
3
|
+
### 描述
|
|
4
|
+
|
|
5
|
+
Fastify 单机应用市场插件:版本上传、PM2 托管、路径/域名反代与日志 SSE
|
|
6
|
+
|
|
7
|
+
### 关键词
|
|
8
|
+
|
|
9
|
+
fastify, pm2, app-manager, marketplace, reverse-proxy, gateway, sse, deployment, kne
|
|
10
|
+
|
|
11
|
+
### 安装
|
|
12
|
+
|
|
13
|
+
```shell
|
|
14
|
+
npm i --save @kne/fastify-app-manager
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### 概述
|
|
18
|
+
|
|
19
|
+
#### 项目概述
|
|
20
|
+
|
|
21
|
+
`@kne/fastify-app-manager` 是面向可信团队内部的 **单机应用市场** Fastify 插件:上传 Fullstack Biz App 的 zip 版本、由 PM2 拉起独立 Node 进程、按分配端口反代访问,并提供管理 API、文件日志与 SSE 实时推送。
|
|
22
|
+
|
|
23
|
+
适用场景:内网/堡垒机上的业务应用集中托管;不负责多机编排、硬磁盘配额或 WebSocket 升级。
|
|
24
|
+
|
|
25
|
+
> **关键设计**:子应用前端通过部署时注入的 `runtimePublicUrl` / `runtimeApiUrl` 感知对外前缀;网关对路径模式剥前缀反代,并改写 Location / Cookie / 文本 body 兜底。Scheme B(子进程挂 `APP_BASE_PATH`)已弃用。
|
|
26
|
+
|
|
27
|
+
#### 核心架构与流程
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
Client / Admin UI
|
|
31
|
+
↓
|
|
32
|
+
Nginx (TLS)
|
|
33
|
+
↓
|
|
34
|
+
Host Fastify
|
|
35
|
+
├→ createAuthenticate()(默认 admin)
|
|
36
|
+
├→ Management API {prefix}/app/*
|
|
37
|
+
├→ 其它宿主路由
|
|
38
|
+
└→ Gateway
|
|
39
|
+
├→ Host = app.domain → 透明反代 127.0.0.1:{port}
|
|
40
|
+
└→ /app/{name}/* → 剥前缀反代 127.0.0.1:{port}
|
|
41
|
+
↑
|
|
42
|
+
PM2 进程 + logs/*.log + SSE
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
| 节点 | 说明 |
|
|
46
|
+
|------|------|
|
|
47
|
+
| Management API | 创建应用、上传版本、部署、启停、日志;均走 `createAuthenticate` |
|
|
48
|
+
| Domain gateway | Host 精确匹配且状态为 `running` 时透明反代,不改写路径 |
|
|
49
|
+
| Path gateway | `/app/{name}` 剥前缀;改写绝对路径 Location、Cookie Path、文本类 body |
|
|
50
|
+
| PM2 | 每应用一进程,名 `app-manager__{name}`;stdout/stderr 落 `appsRoot/{name}/logs` |
|
|
51
|
+
| Bootstrap | `onReady` 连接 PM2、挂 log bus、对 `running`/`deploying` 做 reconcile |
|
|
52
|
+
|
|
53
|
+
##### 部署时序
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
uploadVersion (zip)
|
|
57
|
+
↓
|
|
58
|
+
校验包格式 → 解压 → npm install --production(server)
|
|
59
|
+
↓
|
|
60
|
+
deploy(versionId)
|
|
61
|
+
↓
|
|
62
|
+
status=deploying → 可选 SQL 迁移 → inject entryHtml → pm2.start
|
|
63
|
+
↓(立即返回)
|
|
64
|
+
后台 healthCheck → running / error
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
#### 核心概念详解
|
|
68
|
+
|
|
69
|
+
##### 应用与版本
|
|
70
|
+
|
|
71
|
+
| 概念 | 说明 |
|
|
72
|
+
|------|------|
|
|
73
|
+
| **App** | 元信息、分配端口、env、pm2Config、状态机;落盘目录 `appsRoot/{name}` |
|
|
74
|
+
| **AppVersion** | 一次 zip 上传产物;`artifactPath` 存解压后的包;部署时按 id 或 version 选择 |
|
|
75
|
+
| **端口池** | 默认 `4000–7999`,创建时分配未占用端口;`PORT` 强制写入子进程 env |
|
|
76
|
+
|
|
77
|
+
##### 状态机
|
|
78
|
+
|
|
79
|
+
| 状态 | 说明 | 典型流转 |
|
|
80
|
+
|------|------|----------|
|
|
81
|
+
| `idle` | 已创建、未部署 | → `deploying` |
|
|
82
|
+
| `deploying` | 正在拉起 / 健康检查中 | → `running` / `error` |
|
|
83
|
+
| `running` | 健康检查通过 | → `stopped` / `deploying`(重启) |
|
|
84
|
+
| `stopped` | 已 stop | → `deploying`(start) |
|
|
85
|
+
| `error` | 启动失败或健康检查超时 | → `deploying`(重新部署/start) |
|
|
86
|
+
|
|
87
|
+
##### 路径注入与网关
|
|
88
|
+
|
|
89
|
+
| 模式 | 注入的 runtime* | 网关行为 |
|
|
90
|
+
|------|-----------------|----------|
|
|
91
|
+
| 未绑定 domain | `/app/{name}` | 剥前缀 + Location/Cookie/body 改写 |
|
|
92
|
+
| 已绑定 domain | `/` | Host 匹配后透明反代,不剥前缀 |
|
|
93
|
+
|
|
94
|
+
> **注意**:axios 等对绝对路径 `/api` 会忽略 `baseURL` 的 path 段。子应用前端应使用 Router `basename`,并对以 `/` 开头的请求补上 `runtimeApiUrl` 前缀。
|
|
95
|
+
|
|
96
|
+
##### 环境变量
|
|
97
|
+
|
|
98
|
+
| 来源 | 优先级与规则 |
|
|
99
|
+
|------|----------------|
|
|
100
|
+
| 宿主 `passthroughEnvKeys` | 仅注入白名单键的当前值;API **不回传**宿主值,只暴露键名列表 |
|
|
101
|
+
| 应用 `env` | 覆盖透传键;响应中匹配 `secretEnvKeyPattern` 的键显示为 `********` |
|
|
102
|
+
| `PORT` | 始终强制为分配端口,不可被 app.env 覆盖 |
|
|
103
|
+
| `save-env` patch | `null` 删除键;`********` 对 secret 键表示保持原值 |
|
|
104
|
+
|
|
105
|
+
##### SQL 迁移
|
|
106
|
+
|
|
107
|
+
对齐 `@kne/fastify-sequelize`:在子应用 `server/{sqlPath}/*.sql` 执行,并用表 `_fs_sql_migrations` 记录已执行文件名。默认 `migrateBeforeStart=false`(可由子应用自身 `RUN_SQL_ON_SYNC` 等机制处理);开启后部署时在启动前预跑。
|
|
108
|
+
|
|
109
|
+
#### 主要特性
|
|
110
|
+
|
|
111
|
+
| 特性 | 说明 |
|
|
112
|
+
|------|------|
|
|
113
|
+
| 版本上传 | Zip Slip 防护、条目数/体积上限、Fullstack 包格式校验、server 生产依赖安装 |
|
|
114
|
+
| 短部署 | `deploy` 立即返回 `deploying`,后台健康检查切 `running`/`error`;可用 SSE 看日志 |
|
|
115
|
+
| 生命周期 | `start` / `stop` / `restart` / `remove`;启动时 reconcile 丢失的 PM2 进程 |
|
|
116
|
+
| 双通道访问 | 自定义域名透明反代 + `/app/{name}` 剥前缀反代 |
|
|
117
|
+
| 日志 | 文件落盘 + 历史分页 + SSE(含回放行与心跳) |
|
|
118
|
+
| 鉴权 | `createAuthenticate` 可注入;默认尝试 `fastify.account.authenticate.admin` |
|
|
119
|
+
|
|
120
|
+
#### 使用方法
|
|
121
|
+
|
|
122
|
+
##### 基本注册
|
|
123
|
+
|
|
124
|
+
```js
|
|
125
|
+
// 宿主须先注册 fastify-sequelize(插件依赖名:fastify-sequelize)
|
|
126
|
+
const Fastify = require('fastify');
|
|
127
|
+
const fastify = Fastify();
|
|
128
|
+
|
|
129
|
+
await fastify.register(require('@kne/fastify-sequelize'), {
|
|
130
|
+
db: { dialect: 'sqlite', storage: './data.sqlite' }
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await fastify.register(require('@kne/fastify-app-manager'), {
|
|
134
|
+
appsRoot: './managed-apps',
|
|
135
|
+
passthroughEnvKeys: ['DB_DIALECT', 'DB_HOST'],
|
|
136
|
+
createAuthenticate: () => [fastify.account.authenticate.admin]
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await fastify.sequelize.sync();
|
|
140
|
+
await fastify.listen({ port: 3000 });
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
##### 典型运维流
|
|
144
|
+
|
|
145
|
+
1. `POST .../app/create` 创建应用(拿到分配端口与 `pathUrl`)
|
|
146
|
+
2. `POST .../app/version/upload` multipart 上传 zip(`name` + `version` + `file`)
|
|
147
|
+
3. `POST .../app/deploy` 传入 `name` + `versionId`(或 `version`)
|
|
148
|
+
4. 打开 `GET .../app/logs/stream?name=` 观察启动日志
|
|
149
|
+
5. 浏览器访问 `/app/{name}/` 或绑定域名
|
|
150
|
+
|
|
151
|
+
##### 包格式要求
|
|
152
|
+
|
|
153
|
+
解压后(或单层顶层目录内)须包含:
|
|
154
|
+
|
|
155
|
+
| 路径 | 要求 |
|
|
156
|
+
|------|------|
|
|
157
|
+
| `package.json` | 根包描述 |
|
|
158
|
+
| `server/package.json` | 服务端依赖 |
|
|
159
|
+
| `server/index.js` | PM2 入口 |
|
|
160
|
+
| `build/` | 已构建前端;含 `index.html` / `entry.html` / `entry-prod.html` 之一 |
|
|
161
|
+
| `server/sql/*.sql` | 可选;存在则 `hasMigration=true` |
|
|
162
|
+
|
|
163
|
+
> **边界**:仅单机;TLS、HTTP/2、WebSocket 升级由外部 nginx/宿主处理。磁盘配额、多机调度不在本插件范围。
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
### 示例
|
|
167
|
+
|
|
168
|
+
### API
|
|
169
|
+
|
|
170
|
+
#### 配置项
|
|
171
|
+
|
|
172
|
+
| 属性名 | 类型 | 必填 | 默认值 | 说明 |
|
|
173
|
+
|--------|------|------|--------|------|
|
|
174
|
+
| `name` | string | 否 | `'appManager'` | 命名空间;挂载为 `fastify[name]` |
|
|
175
|
+
| `prefix` | string | 否 | `'/api/v1/app-manager'` | 管理 API 前缀 |
|
|
176
|
+
| `dbTableNamePrefix` | string | 否 | `'t_app_manager_'` | Sequelize 表名前缀 |
|
|
177
|
+
| `appsRoot` | string | 否 | `cwd/managed-apps` | 应用落盘根目录 |
|
|
178
|
+
| `portMin` | number | 否 | `4000` | 端口池下限(含) |
|
|
179
|
+
| `portMax` | number | 否 | `7999` | 端口池上限(含) |
|
|
180
|
+
| `pathPrefix` | string | 否 | `'/app'` | 路径访问前缀(无尾斜杠亦可) |
|
|
181
|
+
| `passthroughEnvKeys` | array | 否 | `[]` | 允许注入子进程的宿主 env 键名 |
|
|
182
|
+
| `secretEnvKeyPattern` | RegExp | 否 | `/(SECRET\|PASSWORD\|TOKEN\|KEY\|PRIVATE)/i` | 响应脱敏匹配 |
|
|
183
|
+
| `pm2Defaults` | object | 否 | 见下表 | 全局 PM2 默认;可被应用 `pm2Config` 覆盖 |
|
|
184
|
+
| `createAuthenticate` | function | 否 | 见说明 | 返回 `onRequest` 钩子数组;默认尝试 admin |
|
|
185
|
+
| `healthCheckPath` | string | 否 | `'/'` | 部署后就绪探测路径 |
|
|
186
|
+
| `healthCheckTimeoutMs` | number | 否 | `30000` | 探测总超时(毫秒) |
|
|
187
|
+
| `healthCheckIntervalMs` | number | 否 | `1000` | 探测轮询间隔(毫秒) |
|
|
188
|
+
| `maxZipSize` | number | 否 | `209715200` | zip 最大字节(200MB);同步 multipart 限制 |
|
|
189
|
+
| `maxZipEntries` | number | 否 | `20000` | zip 最大条目数 |
|
|
190
|
+
| `npmInstallTimeoutMs` | number | 否 | `600000` | `server` 目录 `npm install` 超时 |
|
|
191
|
+
| `sseReplayLines` | number | 否 | `100` | SSE 连接时回放最近行数 |
|
|
192
|
+
| `sseHeartbeatMs` | number | 否 | `15000` | SSE 心跳间隔 |
|
|
193
|
+
| `logMaxSize` | number | 否 | `52428800` | 预留:单日志文件上限(50MB) |
|
|
194
|
+
| `logRetentionMaxRows` | number | 否 | `10000` | 预留:日志行保留上限 |
|
|
195
|
+
| `sqlPath` | string | 否 | `'sql'` | 相对 `server/` 的 SQL 目录名 |
|
|
196
|
+
| `migrateBeforeStart` | boolean | 否 | `false` | 为 true 且版本含迁移时,启动前执行 SQL |
|
|
197
|
+
|
|
198
|
+
##### pm2Defaults 默认值
|
|
199
|
+
|
|
200
|
+
| 键 | 默认值 |
|
|
201
|
+
|----|--------|
|
|
202
|
+
| `exec_mode` | `'fork'` |
|
|
203
|
+
| `instances` | `1` |
|
|
204
|
+
| `autorestart` | `true` |
|
|
205
|
+
| `max_memory_restart` | `'512M'` |
|
|
206
|
+
| `max_restarts` | `10` |
|
|
207
|
+
| `min_uptime` | `'5s'` |
|
|
208
|
+
| `kill_timeout` | `5000` |
|
|
209
|
+
| `merge_logs` | `true` |
|
|
210
|
+
|
|
211
|
+
应用级 `pm2Config` 仅允许覆盖:`exec_mode`、`instances`、`autorestart`、`max_memory_restart`、`max_restarts`、`min_uptime`、`kill_timeout`、`merge_logs`。
|
|
212
|
+
|
|
213
|
+
> **关键设计**:`createAuthenticate` 默认实现在存在 `fastify.account.authenticate.admin` 时返回该钩子,否则返回空数组(便于本地无 account 插件时调试)。生产环境应显式传入鉴权。
|
|
214
|
+
|
|
215
|
+
> **依赖**:插件 `dependencies: ['fastify-sequelize']`,须先 `register('@kne/fastify-sequelize')` 再注册本插件,并在业务侧调用 `fastify.sequelize.sync()`。
|
|
216
|
+
|
|
217
|
+
#### HTTP 接口
|
|
218
|
+
|
|
219
|
+
以下路径均相对于配置的 `prefix`(默认 `/api/v1/app-manager`)。除特别说明外,均需 `createAuthenticate` 返回的 `onRequest` 钩子。读操作用 GET,写操作用 POST。
|
|
220
|
+
|
|
221
|
+
##### POST `{prefix}/app/create`
|
|
222
|
+
|
|
223
|
+
创建应用并分配端口、初始化落盘目录与空日志文件。
|
|
224
|
+
|
|
225
|
+
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
|
226
|
+
|--------|------|------|--------|------|
|
|
227
|
+
| name | string | 是 | - | slug:`^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$` |
|
|
228
|
+
| label | string | 是 | - | 展示名 |
|
|
229
|
+
| domain | string | 否 | - | 绑定 Host(唯一);冲突返回 409 |
|
|
230
|
+
| icon | string | 否 | - | 图标 |
|
|
231
|
+
| description | string | 否 | - | 描述 |
|
|
232
|
+
| env | object | 否 | `{}` | 应用自有环境变量 |
|
|
233
|
+
| pm2Config | object | 否 | `{}` | PM2 覆盖项 |
|
|
234
|
+
| options | object | 否 | `{}` | 扩展字段 |
|
|
235
|
+
|
|
236
|
+
返回脱敏后的应用对象(含 `port`、`pathUrl`、`passthroughEnvKeys`、`status: 'idle'` 等)。
|
|
237
|
+
|
|
238
|
+
##### POST `{prefix}/app/save`
|
|
239
|
+
|
|
240
|
+
更新元信息。不可通过此接口改 `name` / `port` / `rootPath` / `pm2Name` / `status` / `currentVersionId`。
|
|
241
|
+
|
|
242
|
+
| 参数名 | 类型 | 必填 | 说明 |
|
|
243
|
+
|--------|------|------|------|
|
|
244
|
+
| name | string | 是 | 应用 slug |
|
|
245
|
+
| label / domain / icon / description / env / pm2Config / options | - | 否 | 传入的字段才更新;`env` 按 patch 语义合并 |
|
|
246
|
+
|
|
247
|
+
##### POST `{prefix}/app/save-env`
|
|
248
|
+
|
|
249
|
+
仅合并环境变量。
|
|
250
|
+
|
|
251
|
+
| 参数名 | 类型 | 必填 | 说明 |
|
|
252
|
+
|--------|------|------|------|
|
|
253
|
+
| name | string | 是 | 应用 slug |
|
|
254
|
+
| env | object | 是 | patch:`null` 删键;secret 键值为 `********` 时保持原值 |
|
|
255
|
+
|
|
256
|
+
##### POST `{prefix}/app/version/upload`
|
|
257
|
+
|
|
258
|
+
`multipart/form-data` 上传 zip。
|
|
259
|
+
|
|
260
|
+
| 字段名 | 类型 | 必填 | 说明 |
|
|
261
|
+
|--------|------|------|------|
|
|
262
|
+
| file | file | 是 | zip 二进制(字段名任意 file part) |
|
|
263
|
+
| name | string | 是 | 应用 slug(须已 create) |
|
|
264
|
+
| version | string | 是 | 版本号;同应用内唯一 |
|
|
265
|
+
| label | string | 否 | 版本说明 |
|
|
266
|
+
|
|
267
|
+
成功返回 `appVersion` 记录(含 `artifactPath`、`hasMigration` 等)。包非法或 npm 失败时回滚产物并返回 400。
|
|
268
|
+
|
|
269
|
+
##### GET `{prefix}/app/version/list`
|
|
270
|
+
|
|
271
|
+
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
|
272
|
+
|--------|------|------|--------|------|
|
|
273
|
+
| name | string | 是 | - | 应用 slug |
|
|
274
|
+
| perPage | number | 否 | `20` | 每页条数 |
|
|
275
|
+
| currentPage | number | 否 | `1` | 页码 |
|
|
276
|
+
|
|
277
|
+
返回:
|
|
278
|
+
|
|
279
|
+
```json
|
|
280
|
+
{
|
|
281
|
+
"pageData": [
|
|
282
|
+
{
|
|
283
|
+
"id": "...",
|
|
284
|
+
"appName": "demo",
|
|
285
|
+
"version": "1.0.0",
|
|
286
|
+
"hasMigration": true,
|
|
287
|
+
"sqlFiles": ["001_init.sql"]
|
|
288
|
+
}
|
|
289
|
+
],
|
|
290
|
+
"totalCount": 1
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
`hasMigration` 为 true 时额外附带 `sqlFiles` 列表。
|
|
295
|
+
|
|
296
|
+
##### POST `{prefix}/app/deploy`
|
|
297
|
+
|
|
298
|
+
选择版本部署。立即返回 `deploying`,后台做健康检查。
|
|
299
|
+
|
|
300
|
+
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
|
301
|
+
|--------|------|------|--------|------|
|
|
302
|
+
| name | string | 是 | - | 应用 slug |
|
|
303
|
+
| versionId | string | 否 | - | 版本主键;与 `version` 二选一 |
|
|
304
|
+
| version | string | 否 | - | 版本号 |
|
|
305
|
+
| runMigration | boolean | 否 | `true` | false 时向 app.env 写入 `RUN_SQL_ON_SYNC=false`;预跑 SQL 仍受 `migrateBeforeStart` 约束 |
|
|
306
|
+
|
|
307
|
+
返回示例:
|
|
308
|
+
|
|
309
|
+
```json
|
|
310
|
+
{
|
|
311
|
+
"name": "demo",
|
|
312
|
+
"status": "deploying",
|
|
313
|
+
"versionId": "354173577374729216",
|
|
314
|
+
"port": 4001,
|
|
315
|
+
"pathUrl": "/app/demo/",
|
|
316
|
+
"domain": null
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
##### GET `{prefix}/app/list`
|
|
321
|
+
|
|
322
|
+
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
|
323
|
+
|--------|------|------|--------|------|
|
|
324
|
+
| filter.status | string | 否 | - | 按状态过滤 |
|
|
325
|
+
| filter.keyword | string | 否 | - | 匹配 name / label(LIKE) |
|
|
326
|
+
| perPage | number | 否 | `20` | 每页条数 |
|
|
327
|
+
| currentPage | number | 否 | `1` | 页码 |
|
|
328
|
+
|
|
329
|
+
返回 `{ pageData, totalCount }`,元素均为脱敏后的应用对象。
|
|
330
|
+
|
|
331
|
+
##### GET `{prefix}/app/detail`
|
|
332
|
+
|
|
333
|
+
| 参数名 | 类型 | 必填 | 说明 |
|
|
334
|
+
|--------|------|------|------|
|
|
335
|
+
| name | string | 是 | 应用 slug |
|
|
336
|
+
|
|
337
|
+
##### POST `{prefix}/app/start` | `stop` | `restart` | `remove`
|
|
338
|
+
|
|
339
|
+
| 参数名 | 类型 | 必填 | 说明 |
|
|
340
|
+
|--------|------|------|------|
|
|
341
|
+
| name | string | 是 | 应用 slug |
|
|
342
|
+
|
|
343
|
+
| 动作 | 行为摘要 |
|
|
344
|
+
|------|----------|
|
|
345
|
+
| start | 需已有 `currentVersionId`;拉起进程并后台健康检查 |
|
|
346
|
+
| stop | PM2 stop,状态 `stopped` |
|
|
347
|
+
| restart | 优先 PM2 restart,失败则重新 startProcess |
|
|
348
|
+
| remove | 删 PM2 进程、版本记录、落盘目录与 App 行 |
|
|
349
|
+
|
|
350
|
+
##### GET `{prefix}/app/logs`
|
|
351
|
+
|
|
352
|
+
读取日志文件尾部分页(较新页在前)。
|
|
353
|
+
|
|
354
|
+
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
|
355
|
+
|--------|------|------|--------|------|
|
|
356
|
+
| name | string | 是 | - | 应用 slug |
|
|
357
|
+
| stream | string | 否 | `'out'` | `out` / `err` |
|
|
358
|
+
| perPage | number | 否 | `100` | 每页行数 |
|
|
359
|
+
| currentPage | number | 否 | `1` | 页码(1=最新一页) |
|
|
360
|
+
|
|
361
|
+
返回:
|
|
362
|
+
|
|
363
|
+
```json
|
|
364
|
+
{
|
|
365
|
+
"pageData": [{ "line": 12, "content": "..." }],
|
|
366
|
+
"totalCount": 12
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
##### GET `{prefix}/app/logs/stream`
|
|
371
|
+
|
|
372
|
+
SSE 实时日志。连接时先回放最近 `sseReplayLines` 行,再订阅 `log:{name}`。
|
|
373
|
+
|
|
374
|
+
| 参数名 | 类型 | 必填 | 说明 |
|
|
375
|
+
|--------|------|------|------|
|
|
376
|
+
| name | string | 是 | 应用 slug |
|
|
377
|
+
| stream | string | 否 | 限定 `out` / `err`;省略则推送全部 |
|
|
378
|
+
|
|
379
|
+
> **运维**:反向代理须关闭缓冲(如 nginx `X-Accel-Buffering: no` 已由服务端设置;仍需 `proxy_buffering off`)。
|
|
380
|
+
|
|
381
|
+
事件示例:
|
|
382
|
+
|
|
383
|
+
```text
|
|
384
|
+
event: log
|
|
385
|
+
data: {"appName":"demo","stream":"out","content":"...","line":1}
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
#### 程序化 API
|
|
389
|
+
|
|
390
|
+
命名空间:`fastify[options.name]`(默认 `fastify.appManager`)。
|
|
391
|
+
|
|
392
|
+
##### services.app
|
|
393
|
+
|
|
394
|
+
| 方法签名 | 说明 |
|
|
395
|
+
|----------|------|
|
|
396
|
+
| `create(data)` | 同 create 接口 |
|
|
397
|
+
| `save({ name, ... })` | 同 save |
|
|
398
|
+
| `saveEnv({ name, env })` | 同 save-env |
|
|
399
|
+
| `list({ filter, perPage, currentPage })` | 列表 |
|
|
400
|
+
| `detail({ name })` | 详情(脱敏) |
|
|
401
|
+
| `uploadVersion({ name, version, label, zipBuffer })` | 上传版本 |
|
|
402
|
+
| `listVersions({ name, perPage, currentPage })` | 版本列表 |
|
|
403
|
+
| `deploy({ name, versionId, version, runMigration })` | 部署 |
|
|
404
|
+
| `start` / `stop` / `restart` / `remove` (`{ name }`) | 生命周期 |
|
|
405
|
+
| `logs({ name, stream, perPage, currentPage })` | 读日志文件 |
|
|
406
|
+
| `findByHost(host)` | 网关:按 domain + `running` 查找 |
|
|
407
|
+
| `findByPathName(name)` | 网关:按 name + `running` 查找 |
|
|
408
|
+
| `appendAppLog(appName, stream, content)` | 追加日志并 emit hub 事件 |
|
|
409
|
+
| `readLastLines(name, stream, n)` | SSE 回放用 |
|
|
410
|
+
| `getLogHub()` | 获取 EventEmitter |
|
|
411
|
+
| `toPublicApp(app)` / `mountPrefix(name)` | 序列化辅助 |
|
|
412
|
+
|
|
413
|
+
##### services.bootstrap
|
|
414
|
+
|
|
415
|
+
| 方法签名 | 说明 |
|
|
416
|
+
|----------|------|
|
|
417
|
+
| `onReady()` | 连接 PM2、挂 log bus、reconcile(由插件 `onReady` 调用) |
|
|
418
|
+
| `onClose()` | 断开 bus / PM2(由插件 `onClose` 调用) |
|
|
419
|
+
| `reconcile()` | 对 DB 中 `running`/`deploying` 但 PM2 未 online 的应用尝试 start |
|
|
420
|
+
|
|
421
|
+
#### 数据模型
|
|
422
|
+
|
|
423
|
+
##### app
|
|
424
|
+
|
|
425
|
+
表名约 `t_app_manager_app`(受 `dbTableNamePrefix` 影响)。
|
|
426
|
+
|
|
427
|
+
| 属性名 | 类型 | 说明 |
|
|
428
|
+
|--------|------|------|
|
|
429
|
+
| id | string | 雪花主键 |
|
|
430
|
+
| name | string | 应用 slug,唯一 |
|
|
431
|
+
| label | string | 展示名 |
|
|
432
|
+
| domain | string / null | 绑定 Host,唯一 |
|
|
433
|
+
| icon | string / null | 图标 |
|
|
434
|
+
| description | string / null | 描述 |
|
|
435
|
+
| env | object | 自有环境变量 |
|
|
436
|
+
| pm2Config | object | PM2 覆盖 |
|
|
437
|
+
| options | object | 扩展字段 |
|
|
438
|
+
| port | number | 分配端口 |
|
|
439
|
+
| status | string | `idle` / `deploying` / `running` / `stopped` / `error` |
|
|
440
|
+
| currentVersionId | string / null | 当前部署版本 id |
|
|
441
|
+
| rootPath | string | 落盘根路径 |
|
|
442
|
+
| pm2Name | string | PM2 进程名,唯一 |
|
|
443
|
+
| message | string / null | 最近状态说明(如健康检查失败原因) |
|
|
444
|
+
|
|
445
|
+
索引:`status`、`domain`;`name` / `pm2Name` / `domain` 唯一。
|
|
446
|
+
|
|
447
|
+
##### appVersion
|
|
448
|
+
|
|
449
|
+
| 属性名 | 类型 | 说明 |
|
|
450
|
+
|--------|------|------|
|
|
451
|
+
| id | string | 雪花主键 |
|
|
452
|
+
| appName | string | 所属应用 name |
|
|
453
|
+
| version | string | 版本号 |
|
|
454
|
+
| label | string / null | 版本说明 |
|
|
455
|
+
| artifactPath | string | 解压目录 |
|
|
456
|
+
| hasMigration | boolean | 是否存在 sql 文件 |
|
|
457
|
+
| migrationPath | string | 相对 server 的 sql 目录,默认 `sql` |
|
|
458
|
+
|
|
459
|
+
唯一约束:`(app_name, version)`。
|
|
460
|
+
|
|
461
|
+
#### 机制说明
|
|
462
|
+
|
|
463
|
+
##### 网关与改写
|
|
464
|
+
|
|
465
|
+
| 机制 | 说明 |
|
|
466
|
+
|------|------|
|
|
467
|
+
| 路由优先级 | 管理 API(`prefix`)不进网关;先 domain 匹配,再 path 前缀匹配 |
|
|
468
|
+
| Location | 相对路径前缀补上 `/app/{name}` |
|
|
469
|
+
| Set-Cookie | `Path=/` 改为 `Path=/app/{name}` |
|
|
470
|
+
| Body | 对文本类 Content-Type,将 `"/static/`、`"/api/`、`"/account/` 等前缀改写;有 `content-encoding` 时跳过 |
|
|
471
|
+
|
|
472
|
+
##### 包校验与安全
|
|
473
|
+
|
|
474
|
+
| 检查 | 失败行为 |
|
|
475
|
+
|------|----------|
|
|
476
|
+
| 非 Buffer / 超 `maxZipSize` / 超 `maxZipEntries` | 抛错 |
|
|
477
|
+
| 条目含 `..` 或逃出目标目录 | Zip Slip,抛错 |
|
|
478
|
+
| 缺根/server package、server/index、build 入口 html | `invalid package` |
|
|
479
|
+
|
|
480
|
+
部署时会将根 `build/` 复制到 `server/build/`,再对 `server/build` 做 entryHtml 注入。
|
|
481
|
+
|
|
482
|
+
##### 环境合并顺序
|
|
483
|
+
|
|
484
|
+
```
|
|
485
|
+
pickHostEnv(passthroughEnvKeys) → app.env → PORT=分配端口(强制)
|
|
486
|
+
```
|
package/index.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const fp = require('fastify-plugin');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
|
|
4
|
+
module.exports = fp(
|
|
5
|
+
async (fastify, options) => {
|
|
6
|
+
options = Object.assign(
|
|
7
|
+
{},
|
|
8
|
+
{
|
|
9
|
+
dbTableNamePrefix: 't_app_manager_',
|
|
10
|
+
name: 'appManager',
|
|
11
|
+
prefix: '/api/v1/app-manager',
|
|
12
|
+
appsRoot: path.join(process.cwd(), 'managed-apps'),
|
|
13
|
+
portMin: 4000,
|
|
14
|
+
portMax: 7999,
|
|
15
|
+
pathPrefix: '/app',
|
|
16
|
+
healthCheckPath: '/',
|
|
17
|
+
healthCheckTimeoutMs: 30000,
|
|
18
|
+
healthCheckIntervalMs: 1000,
|
|
19
|
+
maxZipSize: 200 * 1024 * 1024,
|
|
20
|
+
maxZipEntries: 20000,
|
|
21
|
+
npmInstallTimeoutMs: 10 * 60 * 1000,
|
|
22
|
+
logRetentionMaxRows: 10000,
|
|
23
|
+
sseReplayLines: 100,
|
|
24
|
+
sseHeartbeatMs: 15000,
|
|
25
|
+
logMaxSize: 50 * 1024 * 1024,
|
|
26
|
+
passthroughEnvKeys: [],
|
|
27
|
+
secretEnvKeyPattern: /(SECRET|PASSWORD|TOKEN|KEY|PRIVATE)/i,
|
|
28
|
+
sqlPath: 'sql',
|
|
29
|
+
migrateBeforeStart: false,
|
|
30
|
+
pm2Defaults: {
|
|
31
|
+
exec_mode: 'fork',
|
|
32
|
+
instances: 1,
|
|
33
|
+
autorestart: true,
|
|
34
|
+
max_memory_restart: '512M',
|
|
35
|
+
max_restarts: 10,
|
|
36
|
+
min_uptime: '5s',
|
|
37
|
+
kill_timeout: 5000,
|
|
38
|
+
merge_logs: true
|
|
39
|
+
},
|
|
40
|
+
createAuthenticate: () => {
|
|
41
|
+
if (fastify.account?.authenticate?.admin) {
|
|
42
|
+
return [fastify.account.authenticate.admin];
|
|
43
|
+
}
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
options
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
await fastify.register(require('@fastify/multipart'), {
|
|
51
|
+
limits: {
|
|
52
|
+
fileSize: options.maxZipSize
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await fastify.register(require('@fastify/reply-from'));
|
|
57
|
+
|
|
58
|
+
fastify.register(require('@kne/fastify-namespace'), {
|
|
59
|
+
options,
|
|
60
|
+
name: options.name,
|
|
61
|
+
modules: [
|
|
62
|
+
[
|
|
63
|
+
'models',
|
|
64
|
+
await fastify.sequelize.addModels(path.resolve(__dirname, './libs/models'), {
|
|
65
|
+
prefix: options.dbTableNamePrefix
|
|
66
|
+
})
|
|
67
|
+
],
|
|
68
|
+
['services', path.resolve(__dirname, './libs/services')],
|
|
69
|
+
['controllers', path.resolve(__dirname, './libs/controllers')]
|
|
70
|
+
]
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
fastify.addHook('onReady', async () => {
|
|
74
|
+
const { services } = fastify[options.name];
|
|
75
|
+
await services.bootstrap.onReady();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
fastify.addHook('onClose', async () => {
|
|
79
|
+
const { services } = fastify[options.name];
|
|
80
|
+
await services.bootstrap.onClose();
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'fastify-app-manager',
|
|
85
|
+
dependencies: ['fastify-sequelize']
|
|
86
|
+
}
|
|
87
|
+
);
|