@h-ai/deploy 1.0.0-beta.2 → 1.0.0-beta.21
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.en.md +130 -34
- package/README.ja.md +129 -31
- package/README.md +143 -44
- package/dist/deploy-runtime-env.mjs +230 -0
- package/dist/index.d.ts +347 -102
- package/dist/index.js +9596 -1091
- package/dist/index.js.map +1 -1
- package/package.json +10 -9
package/README.md
CHANGED
|
@@ -2,17 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
[简体中文](README.md) | [English](README.en.md) | [日本語](README.ja.md)
|
|
4
4
|
|
|
5
|
-
## 简介
|
|
6
|
-
|
|
7
5
|
部署已有镜像与 Vercel prebuilt 产物。支持完整离线交付、选择服务更新、SSH 自动发布和跨平台人工部署脚本。SDK 不构建源码,CLI 的 --build 明确编排先构建后发布。
|
|
8
6
|
|
|
9
|
-
##
|
|
7
|
+
## 快速开始
|
|
8
|
+
|
|
9
|
+
在 CLI 创建并已配置 `deploy` 脚本的工程根目录执行。先查看只读计划,核对环境、目标和服务范围,再构建并发布:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pnpm run deploy --env prod --plan
|
|
13
|
+
pnpm run deploy --env prod --build
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
命令返回部署 URL 或离线交付目录;失败时按安全提示及本地/远端执行状态排查。生产凭据放 `.env.prod.local`,部署前准备数据备份与恢复方案。以下功能介绍分别说明构建、配置和交付边界。
|
|
17
|
+
|
|
18
|
+
## 功能
|
|
10
19
|
|
|
11
20
|
- 一条命令完成构建、打包、SSH 上传、启动和访问检查。
|
|
12
21
|
- 完整包包含应用与自建中间件镜像;支持只更新选中的服务。
|
|
13
22
|
- 离线交付生成 tar 和 Windows/Linux/macOS 操作脚本,记录步骤结果并在失败时尝试恢复。
|
|
14
23
|
|
|
15
|
-
|
|
24
|
+
### 功能输入输出
|
|
25
|
+
|
|
26
|
+
以下片段依赖本节完整初始化流程。示例中的业务值由宿主校验和授权后传入;异步领域结果先判断 success,再读取 data。表中 void 表示操作已完成且没有返回数据,null 表示不存在;同步工厂/工具直接返回表中类型。
|
|
27
|
+
|
|
28
|
+
#### deploy
|
|
29
|
+
|
|
30
|
+
部署编排。scan 输入工程路径/环境返回扫描结果;provisionAll 输入工程名/服务范围返回开通数组;deployApp 输入工程/选项返回 URL、交付包及恢复回执,先检查结果。
|
|
31
|
+
|
|
32
|
+
| 入口 | 输入参数 | 成功结果 | 调用示例 |
|
|
33
|
+
| --------------------- | --------------------- | ------------------- | ------------------------------------------------------------------------------------ |
|
|
34
|
+
| `deploy.scan` | appDir, options | `ScanResult` | `await deploy.scan('./apps/orders', { environment: 'prod' })` |
|
|
35
|
+
| `deploy.provisionAll` | projectName, services | `ProvisionResult[]` | `await deploy.provisionAll('orders', ['db', 'cache'])` |
|
|
36
|
+
| `deploy.deployApp` | appDir, options | `DeployResult` | `await deploy.deployApp('./apps/orders', { environment: 'prod', bundleOnly: true })` |
|
|
37
|
+
|
|
38
|
+
#### deploy.credentials
|
|
39
|
+
|
|
40
|
+
本机私有凭据辅助无需初始化。getPath 返回文件路径,load 返回已加载变量名的 HaiResult;save/saveAll 输入键值/映射返回 void HaiResult,不返回真实秘密供展示。
|
|
41
|
+
|
|
42
|
+
| 入口 | 输入参数 | 成功结果 | 调用示例 |
|
|
43
|
+
| ---------------------------- | ---------- | ---------- | --------------------------------------------------------------------- |
|
|
44
|
+
| `deploy.credentials.getPath` | 无参数 | `string` | `deploy.credentials.getPath()` |
|
|
45
|
+
| `deploy.credentials.load` | 无参数 | `string[]` | `deploy.credentials.load()` |
|
|
46
|
+
| `deploy.credentials.save` | key, value | `void` | `deploy.credentials.save('HAI_DEPLOY_PROVIDER_TOKEN', providerToken)` |
|
|
47
|
+
| `deploy.credentials.saveAll` | entries | `void` | `deploy.credentials.saveAll(privateCredentialEntries)` |
|
|
16
48
|
|
|
17
49
|
### 准备构建产物
|
|
18
50
|
|
|
@@ -22,24 +54,37 @@
|
|
|
22
54
|
|
|
23
55
|
```sh
|
|
24
56
|
pnpm build
|
|
25
|
-
pnpm build --target
|
|
26
|
-
pnpm build --target
|
|
57
|
+
pnpm build --target image
|
|
58
|
+
pnpm build --target image --images web
|
|
27
59
|
pnpm build --target desktop --platform windows --arch x64
|
|
28
60
|
```
|
|
29
61
|
|
|
30
|
-
|
|
62
|
+
自动化可使用 `hai build --target image --json` 的 `artifactDir` 指定本次构建。`readDeploymentArtifact` 校验归档路径及 SHA-256,并返回清单中的来源提交、工作区状态和镜像平台;历史清单可以省略这些字段。
|
|
63
|
+
|
|
64
|
+
源码编译、镜像构建、桌面打包分别对应 compile/image/desktop。构建不选择部署环境、不注入运行期密钥。原生安装包在对应操作系统准备 SDK 与 Rust 工具链后构建。
|
|
31
65
|
|
|
32
66
|
#### build 的可选参数
|
|
33
67
|
|
|
34
|
-
| 参数
|
|
35
|
-
|
|
|
36
|
-
| `--target`
|
|
37
|
-
| `--images`
|
|
38
|
-
| `--runtime`
|
|
39
|
-
| `--network`
|
|
40
|
-
| `--no-http-proxy`
|
|
41
|
-
| `--
|
|
42
|
-
|
|
68
|
+
| 参数 | 用途 |
|
|
69
|
+
| ----------------------- | ----------------------------------------- |
|
|
70
|
+
| `--target` | compile(默认)、image、app、desktop |
|
|
71
|
+
| `--images` | serv、web 或 serv,web;只构建导出所选镜像 |
|
|
72
|
+
| `--runtime` | auto(优先 Podman)、podman、docker |
|
|
73
|
+
| `--network` | 容器构建网络,默认引擎设置 |
|
|
74
|
+
| `--no-http-proxy` | 禁用本次构建代理(等价 none) |
|
|
75
|
+
| `--platform` / `--arch` | 原生安装包的系统与架构 |
|
|
76
|
+
|
|
77
|
+
#### 下载源和代理
|
|
78
|
+
|
|
79
|
+
SQLite 13 的原生二进制随 npm 包交付,使用同一 npm 源,无须额外下载器。构建会实际打开内存数据库,确认目标平台的二进制可加载。
|
|
80
|
+
|
|
81
|
+
`--image-source official|cn|custom` 同时作用于应用基础镜像和打包时缺少的中间件镜像。`--npm-source` 随 `--build` 控制 Corepack/pnpm;cn 使用毫秒镜像的 Docker Hub/GHCR 通道和 npmmirror。首次交互保存 `hai.json.build.sources`,命令覆盖只作用本次。代理使用 `--proxy-mode inherit|system|custom|none`、`--proxy-url` 与 `--proxy-bypass`;`--no-http-proxy` 等价 none。Docker/远程 Podman 的镜像拉取代理须在引擎侧设置,构建容器另做可达性检查。自定义源与平台范围见[下载源和代理](../../docs/usage/configuration/delivery.md#下载源和代理).
|
|
82
|
+
|
|
83
|
+
SDK 的 `DeployAppOptions.imageSource` 为 `{ type: 'cn' }` 或 `{ type: 'custom', registries: { 'docker.io': 'mirror.example.com/docker.io', 'ghcr.io': 'mirror.example.com/ghcr.io' } }`,`proxy` 为 `{ mode: 'inherit' }` 或 `{ mode: 'custom', url: 'http://127.0.0.1:10808', bypass: 'localhost' }`。缺省为官方源、继承进程代理。`resolveImageReference()` / `resolveDownloadProxy()` 是无需 init 的解析工具,不修改引擎设置。中间件归档保留原始引用,服务器继续离线导入。
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
pnpm run deploy --env prod --build --bundle-only --image-source cn --npm-source cn
|
|
87
|
+
```
|
|
43
88
|
|
|
44
89
|
#### 用 hai.json 描述要打什么包
|
|
45
90
|
|
|
@@ -80,7 +125,7 @@ pnpm build --target desktop --platform windows --arch x64
|
|
|
80
125
|
|
|
81
126
|
每个镜像独立 Dockerfile;web 的 Nginx 配置与启动资源放 `images/web/`。Dockerfile 内保留编译/运行阶段,配置不再选择阶段。没有 Web 或 desktop 时删除相应项。
|
|
82
127
|
|
|
83
|
-
业务容量通过 `transport.maxPlaintextBytes` 统一配置:默认 1 MiB,整数范围 1 KiB~64 MiB,请求和响应共用 UTF-8 明文字节预算。客户端从握手自动获取上限,密文与部署代理预算由 `resolveTransportBodyLimits()` 推导(两倍明文 + 32 KiB)。握手、公钥、认证头和防重放约束独立;超限取消原流并释放 reader,请求 413、响应为加密的 500 容量错误,客户端识别 `TransportBodyTooLargeError` / `hai:crypto:070`。 全量和 app 增量部署都从解析后的 `_serv.yml` 推导代理上限;容量变化会复用现有 Web 镜像重建代理,健康失败恢复旧配置。SvelteKit 的 adapter-node `BODY_SIZE_LIMIT` 从 `_kit.yml` 同步推导。 [Transport capacity
|
|
128
|
+
业务容量通过 `transport.maxPlaintextBytes` 统一配置:默认 1 MiB,整数范围 1 KiB~64 MiB,请求和响应共用 UTF-8 明文字节预算。客户端从握手自动获取上限,密文与部署代理预算由 `resolveTransportBodyLimits()` 推导(两倍明文 + 32 KiB)。握手、公钥、认证头和防重放约束独立;超限取消原流并释放 reader,请求 413、响应为加密的 500 容量错误,客户端识别 `TransportBodyTooLargeError` / `hai:crypto:070`。 全量和 app 增量部署都从解析后的 `_serv.yml` 推导代理上限;容量变化会复用现有 Web 镜像重建代理,健康失败恢复旧配置。SvelteKit 的 adapter-node `BODY_SIZE_LIMIT` 从 `_kit.yml` 同步推导。 [Transport capacity](../crypto/TRANSPORT-CAPACITY.md).
|
|
84
129
|
|
|
85
130
|
无正文认证封装通过 X-Hai-Transport 头传输,生成配置使用 large_client_header_buffers 4 32k 与 proxy_buffer_size 32k 容纳头字段;自有代理也须允许并保留该头。
|
|
86
131
|
|
|
@@ -92,19 +137,17 @@ pnpm build --target desktop --platform windows --arch x64
|
|
|
92
137
|
|
|
93
138
|
默认网站位于根目录。需要`https://example.com/yyy/`时,在应用构建进程显式设置`PUBLIC_BASE_PATH=/yyy`,由CLI生成格式5制品;webBasePath是构建结果,不是部署环境的可覆盖参数。各目录段仅ASCII字母、数字、`_`、`.`、`-`,无末尾斜杠或单独的`.`/`..`段。使用支持格式5的CLI/Deploy,旧工具拒绝该格式;不能手改编号绕过。
|
|
94
139
|
|
|
95
|
-
SDK据制品生成Nginx目录、运行配置入口、SSR健康路径和返回URL,额外网关应保留此前缀。同源API仍由Serv的http.apiPrefix独立决定,CORS允许源只包含协议、域名和端口。独立Vite站点保留其运行配置中的外部API地址。完整及部分构建保持同一PUBLIC_BASE_PATH,即使只构建serv;增量在替换前拒绝目录不一致,目录变更使用全量部署。失败恢复按旧发布记录的健康路径验证。操作示例、两种框架差异与真实截图见[网站子目录配置](../../docs/usage/configuration/delivery.md#网站部署在子目录)
|
|
140
|
+
SDK据制品生成Nginx目录、运行配置入口、SSR健康路径和返回URL,额外网关应保留此前缀。同源API仍由Serv的http.apiPrefix独立决定,CORS允许源只包含协议、域名和端口。独立Vite站点保留其运行配置中的外部API地址。完整及部分构建保持同一PUBLIC_BASE_PATH,即使只构建serv;增量在替换前拒绝目录不一致,目录变更使用全量部署。失败恢复按旧发布记录的健康路径验证。操作示例、两种框架差异与真实截图见[网站子目录配置](../../docs/usage/configuration/delivery.md#网站部署在子目录).
|
|
96
141
|
|
|
97
142
|
### 部署到环境
|
|
98
143
|
|
|
99
144
|
#### 先准备环境与部署目标
|
|
100
145
|
|
|
101
|
-
默认部署名取工程根(最近的 `hai.json` 或锁文件所在目录)的 `package.json.name`,缺失时报错,再附加显式环境后缀。例如 fullstack 工程 `hai-delivery` 扫描 `packages/hai-delivery-serv`,`--env test` 生成 `/opt/hai/apps/hai-delivery-test` 和 `bundle-hai-delivery-test-<部署ID>-<版本>.tar`。远端目录、交付包、Compose 项目、API/Web/中间件容器、数据卷、服务器凭据状态及增量基线统一使用该名称;不会按字符串删除真实项目名中的 `-serv`。
|
|
102
|
-
|
|
103
|
-
制品格式4和5都记录primaryService(serv/web),hasWeb表示完整工程是否包含浏览器端;格式5另记录非根目录webBasePath。只有serv + web才生成同源API代理。格式1至3制品须重新构建。旧部署先备份并核对项目名、数据卷和受管凭据,再切换到新的完整部署基线;工具不自动跨项目迁移数据。完整部署仍会按Compose规则清理同项目的旧服务容器,不能仅修改deploy-target.json.directory完成迁移。
|
|
146
|
+
默认部署名取工程根(最近的 `hai.json` 或锁文件所在目录)的 `package.json.name`,缺失时报错,再附加显式环境后缀。例如 fullstack 工程 `hai-delivery` 扫描 `packages/hai-delivery-serv`,`--env test` 生成 `/opt/hai/apps/hai-delivery-test` 和 `bundle-hai-delivery-test-<部署ID>-<版本>.tar.gz`。远端目录、交付包、Compose 项目、API/Web/中间件容器、数据卷、服务器凭据状态及增量基线统一使用该名称;不会按字符串删除真实项目名中的 `-serv`。
|
|
104
147
|
|
|
105
|
-
|
|
148
|
+
全站仅开放 `/somepath/` 时,构建设置 `PUBLIC_BASE_PATH=/somepath`,并将 Serv 的 `http.apiPrefix`、健康/就绪和文档路径及存储 `publicEndpoint` 显式配置到同一目录。SDK 按这些有效配置生成代理和浏览器 API 地址;外层网关保留前缀,内部探活和 ACME 根路径单独处理。[完整配置与验收](../../docs/usage/configuration/delivery.md#所有对外业务路径必须带同一目录).
|
|
106
149
|
|
|
107
|
-
|
|
150
|
+
制品格式4和5都记录primaryService(serv/web),hasWeb表示完整工程是否包含浏览器端;格式5另记录非根目录webBasePath。只有serv + web才生成同源API代理。格式1至3制品须重新构建。旧部署先备份并核对项目名、数据卷和受管凭据,再切换到新的完整部署基线;工具不自动跨项目迁移数据。完整部署仍会按Compose规则清理同项目的旧服务容器,不能仅修改deploy-target.json.directory完成迁移。
|
|
108
151
|
|
|
109
152
|
准备 `.env.<环境>`(例如 `.env.prod`)和项目根 `hai.json`,其中 `build` 描述镜像与桌面构建,`deploy` 描述整个应用的部署。fullstack 的 deploy 参数仍指向 `packages/<项目>-serv`,API workspace 指向 `apps/<项目>-serv`,仅用于扫描运行依赖和模块 YAML;前后端构建与部署配置统一在项目根。
|
|
110
153
|
|
|
@@ -112,10 +155,11 @@ CLI 的 --project-name 与 SDK 的 DeployAppOptions.projectName 覆盖入口已
|
|
|
112
155
|
|
|
113
156
|
部署 SDK 的环境优先级从高到低为:
|
|
114
157
|
|
|
115
|
-
|
|
116
|
-
|
|
158
|
+
所有 `HAI_*` 统一使用:进程 > `.env.<mode>.local` > `.env.local` > `.env.<mode>` > `.env` > YAML。同来源 JSON 为基础,细项继续覆盖;高来源整段替换先清掉低来源细项。Node/Vite 与部署共用 Core 合并,不维护模块专用顺序。
|
|
159
|
+
|
|
160
|
+
公共配置保存在 `.env` 和 `.env.<mode>`,连接凭据与 SSH 配置保存在忽略 Git 的 `.env.<mode>.local`,`.env.local` 用于本机共享私有值。构建不读取部署秘密。
|
|
117
161
|
|
|
118
|
-
|
|
162
|
+
HTTP API 的自定义请求头整体视为秘密,不按头名猜测是否包含凭据。保存已有环境时,缩进、`export` 与多行引号的旧项也会从同模式公共文件移入私有文件,避免原值残留。
|
|
119
163
|
|
|
120
164
|
已有项目需把原构建对象移入 `hai.json.build`,把原部署 YAML 转成 JSON 放入 `hai.json.deploy`,删除原文件并保留现有 `HAI_DEPLOY_*` 变量。已有自建 S3 的项目首次升级必须全量部署:旧成功基线仍含已退出的一次性初始化服务,直接使用新增量包会被依赖存活检查拒绝;全量部署同时刷新中间件环境中的 `createBucket`,之后即可继续增量更新。
|
|
121
165
|
|
|
@@ -125,13 +169,17 @@ SSH 参数保存到选中环境的 `.env.<环境>.local`。`--bundle-only` 在
|
|
|
125
169
|
|
|
126
170
|
#### 按应用真正需要的中间件处理
|
|
127
171
|
|
|
128
|
-
|
|
172
|
+
根据目标应用锁文件的运行依赖和模块配置识别数据库、缓存、文件存储、向量库、LDAP、邮件等;仅保留注释配置不会启用模块。LDAP 只有在有效配置 `iam.login.ldap=true` 时才列入部署需求,未启用时不询问。逐项选择默认、自定义或保持。目标环境已有完整配置时,`--yes` 保持原值,不重新输入或重复云开通;交互模式仍可选择本次处理范围。配置完备可用 `--skip-middleware` 跳过向导和开通。SQLite、Memory cache、本地存储不产生外部镜像;Compose 自建服务的镜像自动加入完整交付包。外部服务仍需目标网络可达,云开通不属于完全离线部署。
|
|
173
|
+
|
|
174
|
+
默认和自定义模式均保留同后端的有效配置,包括表单未展示的连接池、操作日志、存储公开端点/前缀、Redis 重试和向量检索选项。自建与云开通仅重新定稿实际连接和认证;旧 URL、TLS/集群模式与凭据不能覆盖新服务,SMTP 发件人和 LDAP 属性映射继续保留。切换后端时清除不兼容字段。 生成端优先写细项,数组保留 JSON;手写 JSON 允许兜底,放在对应模块细项之前。同源细项覆盖 JSON,动态字典、JSON 文本和显式空对象保留原义;整体替换展开时以最上层 `{}` 标记阻止旧 YAML 字段恢复。容器路径只写交付配置,不反写工程源环境。
|
|
175
|
+
|
|
176
|
+
通过 `hai deploy` 自定义 PostgreSQL、MySQL、Redis 或 pgvector 时可在连接 URL 和分字段之间选择。外部 S3 的 `endpoint` 填服务端 API 地址,桶名填 `bucket`。填写方式及示例见[连接已有中间件](../../docs/usage/configuration/delivery.md#连接已有数据库缓存和对象存储)。 生产 Reach 的空列表要求单独提供完整的通知配置,向导按实际列表索引配置。
|
|
129
177
|
|
|
130
178
|
#### 私有对象存储直连
|
|
131
179
|
|
|
132
180
|
自建 S3 的 `endpoint` 使用 Compose 内部地址,浏览器地址用 `_storage.yml` 的 `publicEndpoint` 配置,例如 `https://app.example.com/delivery/files`。Nginx 对外开放 `/delivery/files/<bucket>/...`,去掉 `/delivery/files` 后将原始桶 URI、签名查询与 Host 直接转发 storage:9000,文件不经过 Serv;省略目录时继续使用根桶路径。入口可位于网站目录内,API/资源/健康路径冲突会在打包前失败。存储端口与控制台不公开,对象缓冲和签名日志关闭;S3 负责私有桶授权和跨源 CORS。外层代理须保留完整入口路径与 Host,只由 SDK 网关去掉前缀。
|
|
133
181
|
|
|
134
|
-
自建配置保留 bucket/region/prefix/publicEndpoint/publicUrl/operationLog,只替换内部连接与托管凭据。首次增加网关或改变这些访问设置须全量部署,增量预检按配置摘要拒绝不一致;普通应用/存储增量刷新保留代理的服务 DNS,失败恢复使用旧拓扑。外部/保持连接不生成本地存储路由,Local 不公开私有目录。详见[存储配置手册](../../docs/usage/configuration/data.md#配置人员区分三个访问地址)
|
|
182
|
+
自建配置保留 bucket/region/prefix/publicEndpoint/publicUrl/operationLog,只替换内部连接与托管凭据。首次增加网关或改变这些访问设置须全量部署,增量预检按配置摘要拒绝不一致;普通应用/存储增量刷新保留代理的服务 DNS,失败恢复使用旧拓扑。外部/保持连接不生成本地存储路由,Local 不公开私有目录。详见[存储配置手册](../../docs/usage/configuration/data.md#配置人员区分三个访问地址).
|
|
135
183
|
|
|
136
184
|
#### deploy 的可选参数
|
|
137
185
|
|
|
@@ -169,17 +217,19 @@ S3 的 `createBucket` 默认为 `false`;部署器为自建 S3 注入 `HAI_STOR
|
|
|
169
217
|
pnpm run deploy --env prod --build --bundle-only
|
|
170
218
|
```
|
|
171
219
|
|
|
172
|
-
每次交付只生成一个 `.hai/deploy/<环境>/bundle-<项目>-<部署ID>-<版本>/` 目录,外层仅保留 tar、deploy.sh、deploy.ps1、deploy-target.json、read-target.awk、deploy-local.sh 和 README.txt;Compose、运行环境、web.conf 和 manifest.json 仅保存在 tar 内,README 同时记录部署 ID,直接整体复制即可。显式 `--output release-prod.tar` 按命令执行目录生成 `release-prod-<版本>/release-prod-<版本>.tar
|
|
220
|
+
每次交付只生成一个 `.hai/deploy/<环境>/bundle-<项目>-<部署ID>-<版本>/` 目录,外层仅保留 tar、deploy.sh、deploy.ps1、deploy-target.json、read-target.awk、deploy-local.sh 和 README.txt;Compose、运行环境、web.conf 和 manifest.json 仅保存在 tar 内,README 同时记录部署 ID,直接整体复制即可。显式 `--output release-prod.tar` 按命令执行目录生成 `release-prod-<版本>/release-prod-<版本>.tar`,其他文件仍在同一交付目录。包包含所选应用/中间件镜像、服务配置、私有环境、校验和、`APPLY.txt` 和 `apply.sh`。运行上传脚本前,先打开操作目录的 `deploy-target.json`,按其中逐项说明修改并核对服务器、SSH 用户和端口、私钥及指纹文件、远程目录等配置。Windows 运行 deploy.ps1,Linux/macOS 运行 deploy.sh;两个脚本均读取该 JSON,通过 SSH 上传后执行同一服务器流程。本机运行 deploy.sh 需 sh、awk、ssh、scp;可用 `sh deploy.sh 主机 [端口] [用户]` 覆盖连接地址,或给 deploy.ps1 传入 `-IdentityFile` 覆盖私钥。两个脚本均支持用 HAI_DEPLOY_IDENTITY_FILE、HAI_DEPLOY_KNOWN_HOSTS_FILE 覆盖私钥及指纹文件路径。换电脑或操作系统后,这些路径必须指向当前电脑上的实际文件。
|
|
173
221
|
|
|
174
222
|
离线上传支持密钥和密码认证。`deploy-target.json` 只保存认证方式及 `passwordEnv` 名称(默认 `HAI_DEPLOY_PROVIDER_SSH_PASSWORD`),不保存密码值。密码由操作机环境提供,仅传给本次 SSH/SCP 子进程,不进入应用环境、tar 或 manifest;Windows 也可读取用户持久环境。Windows 入口要求 PowerShell 5+ 和 OpenSSH,Linux/macOS 使用 sh、awk、ssh、scp,两者均不需要 Node/pnpm。修改 target 只影响当前交付目录,不回写工程;新交付仍使用工程保存的环境配置。
|
|
175
223
|
|
|
176
224
|
上传优先复用操作机环境中的密码;未配置或为空时,`deploy.sh` / `deploy.ps1` 会直接要求隐藏输入,并继续本次部署。输入不保存到文件;非交互调用明确失败,不等待输入。Linux/macOS 交互输入需要终端和 `stty`。密码变量名、无效输入及取消均有明确提示。上传显示已接收字节、总字节和百分比,成功后才显示 100%;PowerShell 持续转发 SSH 的脱敏输出。增量发布遇到已停止或 Compose down 的服务时,先用已安装基线和本地镜像恢复,再检查依赖并更新所选服务;恢复失败不会推进 current。
|
|
177
225
|
|
|
178
|
-
|
|
226
|
+
在 `hai.json` 的 `deploy.container.compose.services.serv.extra_hosts` 或 `web.extra_hosts` 中配置主机名到 IP 的映射。映射随归档交付,增量更新只替换选中服务,失败恢复使用上一成功发布的映射。 [配置示例](../../docs/usage/configuration/delivery.md#为容器设置主机名映射)
|
|
227
|
+
|
|
228
|
+
完全隔离的 Linux 服务器上,复制整个交付目录后运行 `sh deploy-local.sh`;脚本自动校验和解包同目录 tar,再调用包内 apply.sh,也可传入部署根目录覆盖默认值。只复制归档时,手工解压后阅读 `APPLY.txt`,在同目录执行 `sh ./apply.sh`。每步检查结果,任何失败返回非零退出码;镜像导入、服务启动和 HTTP 检查分别报告。结果写到服务器部署目录 `result-<部署ID>.json`,只有检查通过才更新 current 成功状态。重复应用同一部署包会明确停止;相同版本重新生成的新包允许发布。已有版本失败时尝试恢复选中的旧容器和配置,保留数据卷及诊断材料;不会回退已写入的业务数据。
|
|
179
229
|
|
|
180
230
|
离线包的 `manifest.json` 用 `partial` 与 `selectedServices` 明确记录模式和本次服务;执行脚本固化同一选择,不靠文件名猜测。不指定 `--services` 且使用完整构建时为全量;指定 `--services` 或使用部分构建时为增量。增量包必须匹配服务器 `current` 的成功基线,首次安装会拒绝。
|
|
181
231
|
|
|
182
|
-
服务器使用 util-linux `flock` 对部署根目录的 `.deploy.lock` 持有非阻塞内核锁,覆盖部署和失败恢复。正常前台部署及同步失败恢复收尾、发布终态后显式解锁,后台服务继续运行不阻塞后续部署。锁文件永久保留,禁止删除、替换或按经过时间抢锁;目录所在文件系统须支持 `flock`。HUP/INT/TERM 中断或父 shell 被强杀后,继承文件描述符的活跃子命令继续持锁,所有 holder
|
|
232
|
+
服务器使用 util-linux `flock` 对部署根目录的 `.deploy.lock` 持有非阻塞内核锁,覆盖部署和失败恢复。正常前台部署及同步失败恢复收尾、发布终态后显式解锁,后台服务继续运行不阻塞后续部署。锁文件永久保留,禁止删除、替换或按经过时间抢锁;目录所在文件系统须支持 `flock`。HUP/INT/TERM 中断或父 shell 被强杀后,继承文件描述符的活跃子命令继续持锁,所有 holder 退出或服务器重启后才释放。强杀/断电不会运行恢复 trap,缺少结果文件时执行状态未知:先核对容器、镜像和 `current` 成功基线,再用新部署包恢复,不能把锁释放视为部署成功或副作用已回滚。
|
|
183
233
|
|
|
184
234
|
#### 失败恢复与发布后回滚
|
|
185
235
|
|
|
@@ -212,12 +262,29 @@ SDK 在服务器本地健康通过并提交 `current` 后,还从操作机检
|
|
|
212
262
|
- 包含私有配置,安全搬运,不提交 Git;持久化卷身份跨版本稳定。
|
|
213
263
|
- 实际断外网验证启动、登录、页面刷新、重启与数据持久化。生产 HTTPS、证书、DNS 和外部服务需另行准备。
|
|
214
264
|
|
|
265
|
+
### 构建与交付优化
|
|
266
|
+
|
|
267
|
+
镜像默认最多并行两项,`--concurrency 1` 可降低资源占用。Dockerfile 在复制源码前安装依赖;前后端仅复制各自必要源码,复用安装层和驱动缓存。运行镜像移除应用自身的声明文件、源码映射和说明文件。打包按镜像内容 ID 复用并校验中间件归档,复制应用镜像时同时计算摘要。默认交付压缩为 `.tar.gz`,显式 `--output release.tar` 可保留未压缩格式;交付目录包含 `README.txt`,归档内含手工部署说明 `APPLY.txt`。成功部署后清理本次 incoming 上传和解包目录,release 保留恢复配置和状态,镜像由容器引擎保存。
|
|
268
|
+
|
|
269
|
+
### 可选 HTTPS 证书
|
|
270
|
+
|
|
271
|
+
`pnpm run deploy` 为所有容器模板统一提供“不启用 / 已有 PEM 证书和私钥路径 / Let’s Encrypt”。相对路径以工程根为基准,已有证书校验域名、有效期和密钥匹配后复制到私有发布包。Let’s Encrypt 使用固定版本官方 Certbot 镜像申请,每 12 小时检查续期,Nginx 自动重载。需要确认服务条款、真实邮箱、公网 DNS、80 端口和 CA 网络;隔离环境使用已有证书。证书状态保留在服务器,私钥不进入应用镜像。`--yes` 复用保存配置。
|
|
272
|
+
|
|
273
|
+
只接受末级无软链接或硬链接的普通 PEM 文件,每份不超过 1 MiB;父目录仍须可信。Certbot `live/` 的软链接需安全复制为受控目录中的普通文件,或使用框架 `letsencrypt` 模式。
|
|
274
|
+
|
|
275
|
+
配置为 `hai.json.deploy.provider.expose.https`,也可用环境私有文件覆盖。默认 HTTPS 443、HTTP 跳转开启,未配置保持 HTTP;启停或更改模式、域名、端口、本地证书需全量部署。配置和续期边界见[HTTPS 手册](../../docs/usage/configuration/delivery.md#可选https证书)。`--plan` 的 `root` 表示工程根,`serviceDir` 表示部署服务源码与配置目录,仅显示公开证书元数据。
|
|
276
|
+
|
|
215
277
|
### SDK
|
|
216
278
|
|
|
217
279
|
```ts
|
|
218
280
|
import { deploy } from '@h-ai/deploy'
|
|
219
281
|
|
|
220
|
-
const initialized = await deploy.init({
|
|
282
|
+
const initialized = await deploy.init({
|
|
283
|
+
provider: {
|
|
284
|
+
type: 'docker-ssh',
|
|
285
|
+
expose: { hostPort: 18080 }
|
|
286
|
+
}
|
|
287
|
+
})
|
|
221
288
|
if (!initialized.success)
|
|
222
289
|
throw new Error(initialized.error.message)
|
|
223
290
|
try {
|
|
@@ -239,6 +306,8 @@ finally {
|
|
|
239
306
|
|
|
240
307
|
`selectedServices: ['web']` 选择局部更新,`onProgress` 返回打包与服务器执行阶段。`deploy.scan` 和 `deploy.credentials.*` 可在初始化前使用。先判断 HaiResult.success 再读取数据。Vercel 使用 prebuilt API 上传已有 .vercel/output,需要网络,不提供离线交付或 Compose 局部发布。
|
|
241
308
|
|
|
309
|
+
Vercel API 与文件上传固定请求官方入口,拒绝重定向,每次请求及完整 JSON 响应读取共用 30 秒期限;没有新增配置项。超时不证明平台未受理部署,应先在平台查询结果再决定后续操作,避免无条件重复发布。
|
|
310
|
+
|
|
242
311
|
命令执行按正确解码的 UTF-8 逻辑流脱敏,已知秘密即使跨 stdout/stderr 数据块也不会原样释放;成功捕获结果、错误与进度回调遵循同一边界。调用内部命令工具时用 `secrets` 明确列出敏感字面量;编码、截取或转换后的值须单独列入。每条流默认仅捕获最后 1 Mi 字符,`maxCaptureChars` 可设 1~16 Mi 字符,结果用 `stdoutTruncated` / `stderrTruncated` 标记截断,解析结构化输出前必须检查。`onOutput` 可异步,按序等待并保留 pipe 背压,收到的是全部安全 stdout;部署进度仅解析每行前 512 字符。秘密总长度最多 1 Mi UTF-16 单元,超限或脱敏异常失败关闭;超时表示命令执行失败,远程进程终止仍需确认。
|
|
243
312
|
|
|
244
313
|
命令 deadline 包含 helper 启动和输出处理;超时、输入/输出或回调异常会终止受管理进程。POSIX 使用独立进程组,Windows 通过内嵌 PowerShell/.NET Job Object 先挂起创建、成功分配后恢复,禁止 breakaway;helper、编译或分配失败不会降级为裸进程。Windows 需要系统 PowerShell 和 Add-Type,调用隐藏窗口且不加载用户 profile,参数、环境与 UTF-8 管道保持原样。POSIX 命令须让后代继承进程组;已脱离组的系统守护进程及远端服务不在本地终止确认范围。
|
|
@@ -249,7 +318,25 @@ SSH 中断始终先记 `remoteExecution: 'unknown'`。docker-ssh SDK 用新连
|
|
|
249
318
|
|
|
250
319
|
### 运行环境文件与重复部署
|
|
251
320
|
|
|
252
|
-
|
|
321
|
+
SvelteKit SSR 的 HTTP/HTTPS 均使用独立 `gateway`,后端不公开端口。网关覆盖标准转发头,Compose 固定 adapter 的协议/Host/客户端地址信任;这些值不接受业务运行文件覆盖。adapter 不读取运行期 `ORIGIN`,应用默认不固定构建期 `paths.origin`;变更 SSR 入口拓扑时生成全量交付包。
|
|
322
|
+
|
|
323
|
+
外层 TLS 终止须将负载均衡真实 IP/CIDR 加入 `trustedEdgeProxies`;独立 gateway 仅对白名单真实对端接受单值 `http` / `https`,其他来源和组合值使用本连接协议,外部 `Forwarded` 与主机/端口转发头不直接透传。
|
|
324
|
+
|
|
325
|
+
Node 后端(Serv / SvelteKit SSR)每次启动读取服务器 `<部署目录>/config/.env.runtime`。应用配置与托管连接在此合并,修改 `HAI_STORAGE_PUBLICENDPOINT` 等细项后重启对应后端即可;同一来源的细项覆盖父 JSON。配置目录只读挂载到 `/run/hai/config`,编辑器原子替换文件也会被下一次启动读取。私有代理标记和 Node 启动参数由部署装配管理;静态 Web、中间件原生环境、端口/挂载、Nginx 路由与限额变更仍需部署。仅更新 Web/中间件保留当前应用配置;后端局部更新保留服务器托管连接,其余值来自新包,全量更新采用新包并按已有凭据状态定稿。手工修改先备份,长期配置同步回源环境。
|
|
326
|
+
|
|
327
|
+
每个部署包的外层 `README.txt` 和归档内 `APPLY.txt` 都包含文件索引、环境优先级、当前包的变量名和排障步骤。解包目录的 `config/.env.runtime` 是发布输入,部署根下的是当前运行配置,`releases/<ID>/` 下的是历史记录。外部 PostgreSQL 改密后更新项目源环境并发布后端。部署校验拒绝 URL 与分字段密码冲突,增量切换外部与随包中间件需要改用全量发布。
|
|
328
|
+
|
|
329
|
+
容器打包时,监听地址采用 `0.0.0.0`,端口采用制品声明值(未声明时采用部署配置),并生成 `HOST`、`PORT` 和 Serv 的对应细项。本机 `.env` 中的回环地址与开发端口继续用于本地运行。修改容器端口时更新制品与部署布局后重新部署。
|
|
330
|
+
|
|
331
|
+
Nginx 通过动态上游跟随应用和存储容器的 DNS 地址变化。共用的 `.hai/proxy-start.sh` 作为镜像入口钩子执行,保留镜像原有启动命令,并从容器 `/etc/resolv.conf` 读取解析器,HTTPS 时同时监视证书更新;Docker 与 Podman 均无需固定 DNS 地址。修改运行配置后重启后端,即使容器 IP 变化,代理也会自动恢复连接。
|
|
332
|
+
|
|
333
|
+
同前缀但由进程直接读取的环境标记、初始账号密码和传输密钥环仍保留;它们不属于已被父对象替代的配置。
|
|
334
|
+
|
|
335
|
+
生成端优先写细项,数组保留 JSON;手写 JSON 允许兜底,放在对应模块细项之前。同源细项覆盖 JSON,动态字典、JSON 文本和显式空对象保留原义;整体替换展开时以最上层 `{}` 标记阻止旧 YAML 字段恢复。容器路径只写交付配置,不反写工程源环境。
|
|
336
|
+
|
|
337
|
+
部署归档顶层包含 `apply.sh`、`APPLY.txt`、`manifest.json`、`config/`、`images/` 和 `.hai/`。`config/.env.runtime` 是唯一应用环境文件;`manifest.json.metadata` 集中保存依赖、布局、健康路径、镜像列表、受管绑定等非敏感元数据。`.hai/compose.json` 是完整服务快照;局部更新在服务边界替换并保留未选服务,不串联历史 Compose 文件。`.hai/` 还存原生环境、加载器、Nginx/TLS、策略和校验和,秘密不写入 manifest。辅助脚本合入 `apply.sh`。失败时先恢复原应用配置再恢复服务,凭据、后端账本和数据卷继续保护。交付包目录格式为 2。 运行配置定稿复用本包已导入的 Node 应用镜像,临时容器禁网,不额外下载镜像;服务器无需安装 Node 或 jq。
|
|
338
|
+
|
|
339
|
+
中间件原生变量(`POSTGRES_*`、`MYSQL_*`、`RUSTFS_*`、`MINIO_*`、`LDAP_*`)按镜像契约保留原名,只写私有 `.hai/middleware-runtime.env`,仅由中间件读取。应用连接全部合并到 `config/.env.runtime`。
|
|
253
340
|
|
|
254
341
|
自建或云开通的整体 `HAI_*` 配置会清除被替换的旧叶字段,例如 Redis 覆盖后不再留下 `HAI_CACHE_TYPE=memory`。全局 `~/.hai/credentials.yml` 只补缺项,不能覆盖当前环境/进程的同名键或父配置。部署先启动选中中间件,等待 PostgreSQL/MySQL/Redis/S3 健康,再启动 serv/Web;增量和失败恢复沿用此顺序,未选容器保持不变。
|
|
255
342
|
|
|
@@ -257,37 +344,49 @@ Podman 部署会主动执行容器已有的 healthcheck 并重新核对状态,
|
|
|
257
344
|
|
|
258
345
|
分批启动仅在首批清理孤立容器,后续 serv/Web 启动保留已经就绪的中间件。
|
|
259
346
|
|
|
260
|
-
|
|
347
|
+
创建工程时为 `.env.dev.local`、`.env.test.local`、`.env.prod.local` 分别生成独立初始密码和传输密钥环,文件不提交 Git。克隆工程后通过受保护文件或 Secret Manager 分发对应环境秘密,同环境所有节点使用同一 ring 与共享 registry,不在节点启动时生成。缺少或无效的 `HAI_CRYPTO_TRANSPORT_SERVERKEYRING` 在上传前报错。CLI 和离线说明只提示初始密码变量及来源,不输出值;密码仅用于创建缺失账号,已有账号不被重置。
|
|
261
348
|
|
|
262
|
-
每次交付仍生成
|
|
349
|
+
每次交付仍生成 归档内 `config/.env.runtime`,供后端启动预加载(静态 Web 仍用 Compose env_file);它是生成产物,不是配置记忆源。`NODE_ENV` 默认随目标环境选择:`test` → `test`,`dev/development` → `development`,`prod/production` 及其他模式 → `production`。环境文件中显式设置的 `NODE_ENV` 仍优先于该默认值;`--build` 的构建优化模式不决定运行环境。运行配置变更后需重新打包交付。
|
|
263
350
|
|
|
264
351
|
项目根 `hai.json.deploy` 保存部署结构,`.env.<环境>` 保存公共配置,`.env.<环境>.local` 保存 SSH 及中间件秘密值。环境文件默认来自项目/workspace 根,`--env-dir` 可指定目录。完整配置后可使用 `pnpm run deploy --env test --build --bundle-only --yes` 无交互复用;只想保留现有中间件配置时追加 `--skip-middleware`。交互式仅打包会在最后询问是否保存或修改 SSH 信息;修改交付目录的 `deploy-target.json` 不会回写工程。
|
|
265
352
|
|
|
266
|
-
自建中间件的密码由服务器 `<remoteDir>/managed-secrets.env` 持久保存(权限 0600),应与数据卷一起备份。每次打包只生成首次初始化的随机候选,不依赖打包机缓存;在线 SDK、deploy.sh、deploy.ps1 和 deploy-local.sh 都在部署锁内复用服务器值,同时更新应用连接配置及中间件原生变量。交付 tar
|
|
353
|
+
自建中间件的密码由服务器 `<remoteDir>/managed-secrets.env` 持久保存(权限 0600),应与数据卷一起备份。每次打包只生成首次初始化的随机候选,不依赖打包机缓存;在线 SDK、deploy.sh、deploy.ps1 和 deploy-local.sh 都在部署锁内复用服务器值,同时更新应用连接配置及中间件原生变量。交付 tar 保持不可变,只有服务器私有运行文件使用定稿值。若状态缺失而对应数据卷已存在,部署在变更服务前明确停止,须恢复服务器凭据备份;不会自动换密或删除数据。状态缺失时可按显式预检和凭据导入流程补全,普通部署仍拒绝缺失状态。
|
|
267
354
|
|
|
268
|
-
|
|
355
|
+
### 请求策略文件与可信代理
|
|
269
356
|
|
|
270
|
-
Serv 的 `http.access`、SvelteKit 的 `access` 可引用独立 yml/yaml。部署读取应用实际生效配置,包括 `.env.<环境>` / 私有覆盖中的 `HAI_SERV`、`HAI_SERV_HTTP_ACCESS` 或 `HAI_KIT_ACCESS`,校验文件后把规范化快照放入交付包。运行环境改为 `/run/hai/request-access.yml`,Compose 只读挂载;工程的原始文件引用不会被容器路径覆盖。快照参加 SHA-256
|
|
357
|
+
Serv 的 `http.access`、SvelteKit 的 `access` 可引用独立 yml/yaml。部署读取应用实际生效配置,包括 `.env.<环境>` / 私有覆盖中的 `HAI_SERV`、`HAI_SERV_HTTP_ACCESS` 或 `HAI_KIT_ACCESS`,校验文件后把规范化快照放入交付包。运行环境改为 `/run/hai/request-access.yml`,Compose 只读挂载;工程的原始文件引用不会被容器路径覆盖。快照参加 SHA-256 校验,无效或缺失文件在上传前失败。待上传的归档和其中的策略快照参与完整性校验,不应手工修改;替换 SDK 策略快照时重新打包部署。已部署 Node 后端挂载的 `.env.runtime` 可以按“运行配置重载”修改并重启,改变内联规则不会重写策略快照或 Nginx 配置。
|
|
271
358
|
|
|
272
359
|
全量与包含后端的增量都交付自己的快照;仅更新前端时保留后端原挂载。失败恢复使用旧成功发布的 Compose 和策略文件。运行账号无需 root:发布目录私有,策略文件在受限目录内以只读权限供容器读取。回滚镜像和策略不撤销数据库迁移或已经产生的业务效果。
|
|
273
360
|
|
|
274
|
-
如需由运维独立改规则、重启后生效,可在交付之外管理只读文件,并用 Compose override 替换同一 `/run/hai/request-access.yml` 挂载。该外部文件不参与 SDK
|
|
361
|
+
如需由运维独立改规则、重启后生效,可在交付之外管理只读文件,并用 Compose override 替换同一 `/run/hai/request-access.yml` 挂载。该外部文件不参与 SDK 自动回滚,首次附加挂载需要重新创建容器;后续文件修改后重启即可。自行维护且只用 Compose `env_file` 注入环境的容器,修改该文件仍需重新创建,通常无需重建镜像;这与 SDK 默认挂载读取的后端 `.env.runtime` 不同。步骤与验证边界见[独立文件重启配置](../../docs/usage/configuration/request-access.md#44-运维独立管理文件修改后重启生效).
|
|
275
362
|
|
|
276
|
-
受管 Compose 只有存在 Web/gateway 代理且后端没有公开端口时,才设置 `HAI_DEPLOY_PRIVATE_PROXY=1`。Serv/Kit 默认 `trustedProxies: auto` 在此采用容器网段,其他宿主默认忽略转发头。生成 Nginx 用实际对端覆盖客户端提交的转发头;外层负载均衡需在 Docker SSH Provider 的 `trustedEdgeProxies` 显式配置真实 IP/CIDR,省略时不信任上游,`[]` 同样关闭信任。根本未传到 Nginx 的源 IP 无法恢复,Podman rootless 等环境应实际核验入口日志。完整示例和验证步骤见[请求访问控制手册](../../docs/usage/configuration/request-access.md)
|
|
363
|
+
受管 Compose 只有存在 Web/gateway 代理且后端没有公开端口时,才设置 `HAI_DEPLOY_PRIVATE_PROXY=1`。Serv/Kit 默认 `trustedProxies: auto` 在此采用容器网段,其他宿主默认忽略转发头。生成 Nginx 用实际对端覆盖客户端提交的转发头;外层负载均衡需在 Docker SSH Provider 的 `trustedEdgeProxies` 显式配置真实 IP/CIDR,省略时不信任上游,`[]` 同样关闭信任。根本未传到 Nginx 的源 IP 无法恢复,Podman rootless 等环境应实际核验入口日志。完整示例和验证步骤见[请求访问控制手册](../../docs/usage/configuration/request-access.md).
|
|
277
364
|
|
|
278
|
-
|
|
365
|
+
### 只读部署计划
|
|
279
366
|
|
|
280
|
-
`createDeploymentPlan(appDir, config, options)` 不初始化 Provider、不生成中间件凭据、不创建输出目录、不连接目标。复用实际扫描、环境解析、资源名称与制品 checksum
|
|
367
|
+
`createDeploymentPlan(appDir, config, options)` 不初始化 Provider、不生成中间件凭据、不创建输出目录、不连接目标。复用实际扫描、环境解析、资源名称与制品 checksum 校验,返回工程根 `root`、部署服务目录 `serviceDir`、版本、构建 ID、范围、保留服务、配置文件与变量名、基线要求和恢复目录,省略所有变量值与 SSH 凭据。远端 current 未读取时明确 not-inspected;局部更新仍由执行端严格要求成功基线。Compose 恢复不会回退数据库数据或迁移。CLI 对应 `hai deploy --env prod --plan`。
|
|
281
368
|
|
|
282
369
|
需要目标基线事实时使用 `--plan --inspect-remote`(SDK inspectRemote:true),仅通过 SSH 读取 current 及成功发布目录,报告 verified/missing/unavailable 和实际恢复发布 ID,不写目标。
|
|
283
370
|
|
|
284
371
|
资源清单复用执行端的完整 sidecar 拓扑:`containers` 只列本次选中的显式容器名,LDAP/SMTP 等由 Compose 命名的服务列在 `composeManagedServices`;自建 S3 的 SSR/API gateway 也会列入实际更新或保留范围。`logicalVolumes` 是完整拓扑的 Compose 逻辑卷声明,包含保留服务和应用继续挂载的数据卷,不表示新建或重建这些卷;实际引擎卷名由 Compose 项目作用域决定。未选中间件的 action 为 keep,云开通仅按实际选择范围计算,不展示凭据值。
|
|
285
372
|
|
|
286
|
-
|
|
373
|
+
### 容量与凭据导入
|
|
287
374
|
|
|
288
375
|
传输明文上限保留显式设置;反馈需求超过显式值时拒绝打包并说明冲突。缺省/auto 才按模块预算推导,Web 代理和 Kit BODY_SIZE_LIMIT 使用同一密文预算。计划的 target.runtime 表示远端 provider.remote.runtime,与本地镜像打包 options.runtime 分离;auto 不冒充已探测事实。
|
|
289
376
|
|
|
290
|
-
旧目标可用 `importDeploymentSecrets({ directory, inputFile, runtime })` 或在服务器执行 `hai deploy-secrets <directory> --import <private.json>` 只读核验;显式 `--apply` / dryRun:false 才导入。工具核验项目、容器、卷和实际服务凭据,不猜测旧包、不轮换密码、不重建服务。新 v2 状态支持特殊字符并与部署共用 flock,原子写入权限 0600。常规离线部署无需服务器 Node;一次性导入工具需要受支持的 Node 与已安装的 CLI/deploy。详见[导入与恢复流程](docs/credential-import.md)
|
|
377
|
+
旧目标可用 `importDeploymentSecrets({ directory, inputFile, runtime })` 或在服务器执行 `hai deploy-secrets <directory> --import <private.json>` 只读核验;显式 `--apply` / dryRun:false 才导入。工具核验项目、容器、卷和实际服务凭据,不猜测旧包、不轮换密码、不重建服务。新 v2 状态支持特殊字符并与部署共用 flock,原子写入权限 0600。常规离线部署无需服务器 Node;一次性导入工具需要受支持的 Node 与已安装的 CLI/deploy。详见[导入与恢复流程](docs/credential-import.md).
|
|
378
|
+
|
|
379
|
+
## 配置
|
|
380
|
+
|
|
381
|
+
生命周期:init 输入后端/业务配置,成功后才能使用;close 在释放借用依赖前等待完成。返回值以快速开始示例和公开签名为准。
|
|
382
|
+
|
|
383
|
+
配置由宿主加载和校验,再传入本模块;私密值放服务端私有环境,不传到浏览器。按实际使用场景逐项选择后端、容量、期限和日志策略,修改后重新初始化并核验成功结果。
|
|
384
|
+
|
|
385
|
+
[逐项配置说明、适用场景和示例](../../docs/usage/configuration/delivery.md).
|
|
386
|
+
|
|
387
|
+
## 错误处理
|
|
388
|
+
|
|
389
|
+
领域操作先判断 `result.success`,失败按本模块错误定义与安全提示处理,服务端保留错误码、关联编号和脱敏原因。同步工具、工厂、流迭代及生命周期按各自签名处理,不把 Promise 完成视为业务成功。具体失败与恢复边界见对应功能。
|
|
291
390
|
|
|
292
391
|
## 开发测试
|
|
293
392
|
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/** 应用仅加载一份运行配置;部署阶段在隔离 Node 容器内定稿连接与托管密码。 */
|
|
2
|
+
import { Buffer } from 'node:buffer'
|
|
3
|
+
import { closeSync, constants, fstatSync, openSync, readSync } from 'node:fs'
|
|
4
|
+
import { resolve } from 'node:path'
|
|
5
|
+
import process from 'node:process'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { parseEnv } from 'node:util'
|
|
8
|
+
|
|
9
|
+
const maximumBytes = 1024 * 1024
|
|
10
|
+
const refreshCookieSecure = 'HAI_SERV_REFRESH_COOKIE_SECURE'
|
|
11
|
+
// 启动参数、代理身份和 adapter 转发信任均由 Compose 固定,不属于业务可重载配置。
|
|
12
|
+
const startupEnvironment = new Set([
|
|
13
|
+
'NODE_OPTIONS',
|
|
14
|
+
'HAI_DEPLOY_PRIVATE_PROXY',
|
|
15
|
+
'PROTOCOL_HEADER',
|
|
16
|
+
'HOST_HEADER',
|
|
17
|
+
'PORT_HEADER',
|
|
18
|
+
'ADDRESS_HEADER',
|
|
19
|
+
'XFF_DEPTH',
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
/** 拒绝特殊文件、链接、超限与不可读文件,不输出秘密。 */
|
|
23
|
+
function readText(name) {
|
|
24
|
+
let descriptor
|
|
25
|
+
try {
|
|
26
|
+
descriptor = openSync(new URL(name, import.meta.url), constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0))
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new Error('Runtime environment file is missing or not readable')
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const stat = fstatSync(descriptor)
|
|
33
|
+
if (!stat.isFile() || stat.size > maximumBytes)
|
|
34
|
+
throw new Error('Runtime environment file must be a regular file no larger than 1 MiB')
|
|
35
|
+
const buffer = Buffer.alloc(maximumBytes + 1)
|
|
36
|
+
let length = 0
|
|
37
|
+
while (length < buffer.length) {
|
|
38
|
+
const count = readSync(descriptor, buffer, length, buffer.length - length, null)
|
|
39
|
+
if (!count)
|
|
40
|
+
break
|
|
41
|
+
length += count
|
|
42
|
+
}
|
|
43
|
+
if (length > maximumBytes)
|
|
44
|
+
throw new Error('Runtime environment file exceeds 1 MiB')
|
|
45
|
+
return buffer.subarray(0, length).toString('utf8')
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
closeSync(descriptor)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 使用与启动入口一致的 dotenv 字面规则,保留混合引号、UTF-8 和多行值。 */
|
|
53
|
+
function readEnvironment(name) {
|
|
54
|
+
if (name === '-')
|
|
55
|
+
return {}
|
|
56
|
+
try {
|
|
57
|
+
const text = readText(name)
|
|
58
|
+
const values = parseEnv(text)
|
|
59
|
+
const names = new Set()
|
|
60
|
+
for (const match of text.matchAll(/^[ \t]*(?:export[ \t]+)?([A-Z_]\w*)[ \t]*=[ \t]*('[^']*'|"(?:[^"\\]|\\.)*"|`[^`]*`|[^\r\n]*)/gim)) {
|
|
61
|
+
if (names.has(match[1]))
|
|
62
|
+
throw new Error('Duplicate environment variable')
|
|
63
|
+
names.add(match[1])
|
|
64
|
+
if (match[2].startsWith('"') && (match[2].includes('\\"') || match[2].includes('\\u0027'))) {
|
|
65
|
+
try {
|
|
66
|
+
values[match[1]] = JSON.parse(match[2])
|
|
67
|
+
}
|
|
68
|
+
catch { /* 普通 dotenv 双引号仍按 Node 原生规则解析。 */ }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return values
|
|
72
|
+
}
|
|
73
|
+
catch { throw new Error('Runtime environment file could not be parsed') }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 校验最终生效的独立进程开关,允许空值使用 Serv 默认策略。 */
|
|
77
|
+
function validateRefreshCookie(values) {
|
|
78
|
+
const value = values[refreshCookieSecure]
|
|
79
|
+
if (value && value !== 'true' && value !== 'false')
|
|
80
|
+
throw new Error(`Runtime environment file: ${refreshCookieSecure} must be true, false or empty`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 清单只保存非敏感元数据,不把环境值放入错误消息。 */
|
|
84
|
+
function readManifest(name) {
|
|
85
|
+
if (name === '-')
|
|
86
|
+
return { metadata: {} }
|
|
87
|
+
try {
|
|
88
|
+
const value = JSON.parse(readText(name))
|
|
89
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || !value.metadata || typeof value.metadata !== 'object' || Array.isArray(value.metadata)
|
|
90
|
+
|| Object.values(value.metadata).some(item => typeof item !== 'string')) {
|
|
91
|
+
throw new Error('Invalid manifest')
|
|
92
|
+
}
|
|
93
|
+
return value
|
|
94
|
+
}
|
|
95
|
+
catch { throw new Error('Deployment manifest could not be parsed') }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 服务器凭据只在部署时注入最终配置;运行启动器不再读取第二份覆盖文件。 */
|
|
99
|
+
function applyManagedPasswords(values, bindings, state) {
|
|
100
|
+
for (const line of bindings.split('\n').filter(Boolean)) {
|
|
101
|
+
const [group, prefix, scope] = line.split(' ')
|
|
102
|
+
if (scope !== 'prefix' || !Object.keys(values).some(key => key === prefix || key.startsWith(`${prefix}_`)))
|
|
103
|
+
continue
|
|
104
|
+
if (!['DB_PASSWORD', 'STORAGE_PASSWORD', 'LDAP_PASSWORD'].includes(group) || !state[group])
|
|
105
|
+
throw new Error('Managed middleware credential state is missing')
|
|
106
|
+
let password = state[group]
|
|
107
|
+
if (state.FORMAT_VERSION === '2') {
|
|
108
|
+
if (!/^(?:[a-f0-9]{2})+$/.test(password))
|
|
109
|
+
throw new Error('Managed middleware credential encoding is invalid')
|
|
110
|
+
password = Buffer.from(password, 'hex').toString('utf8')
|
|
111
|
+
}
|
|
112
|
+
let configuration = {}
|
|
113
|
+
const parent = Object.hasOwn(values, prefix)
|
|
114
|
+
if (parent) {
|
|
115
|
+
try {
|
|
116
|
+
configuration = JSON.parse(values[prefix])
|
|
117
|
+
if (!configuration || typeof configuration !== 'object' || Array.isArray(configuration))
|
|
118
|
+
throw new Error('Invalid object')
|
|
119
|
+
}
|
|
120
|
+
catch { throw new Error(`Invalid managed application configuration: ${prefix}`) }
|
|
121
|
+
}
|
|
122
|
+
const passwordField = prefix === 'HAI_STORAGE'
|
|
123
|
+
? 'secretAccessKey'
|
|
124
|
+
: prefix === 'HAI_IAM_LDAP'
|
|
125
|
+
? 'bindPassword'
|
|
126
|
+
: 'password'
|
|
127
|
+
if (![
|
|
128
|
+
'HAI_STORAGE',
|
|
129
|
+
'HAI_IAM_LDAP',
|
|
130
|
+
'HAI_DB',
|
|
131
|
+
'HAI_VECDB',
|
|
132
|
+
].includes(prefix)) {
|
|
133
|
+
throw new Error('Unsupported managed middleware binding')
|
|
134
|
+
}
|
|
135
|
+
const url = values[`${prefix}_URL`] ?? configuration.url
|
|
136
|
+
if (['HAI_DB', 'HAI_VECDB'].includes(prefix) && url) {
|
|
137
|
+
try {
|
|
138
|
+
const connection = new URL(url)
|
|
139
|
+
connection.password = encodeURIComponent(password)
|
|
140
|
+
values[`${prefix}_URL`] = connection.toString()
|
|
141
|
+
}
|
|
142
|
+
catch { throw new Error(`Invalid managed application connection: ${prefix}`) }
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
values[`${prefix}_${passwordField.toUpperCase()}`] = password
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 保留父 JSON 与显式细项;粗细覆盖由应用通用配置解析器完成。 */
|
|
151
|
+
function serialize(values) {
|
|
152
|
+
return `${Object.entries(values).sort(([left], [right]) => left.localeCompare(right)).filter(([name]) => !startupEnvironment.has(name)).map(([name, value]) => {
|
|
153
|
+
const literal = !value.includes('\'')
|
|
154
|
+
? `'${value}'`
|
|
155
|
+
: JSON.stringify(value).replaceAll('\'', '\\u0027')
|
|
156
|
+
return `${name}=${literal}`
|
|
157
|
+
}).join('\n')}\n`
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 局部后端发布沿用当前连接,完整发布采用新配置;不改动托管凭据状态。 */
|
|
161
|
+
function prepare(directory, previousManifest, previousEnvironment, credentialState, mode) {
|
|
162
|
+
if (!['full', 'partial'].includes(mode))
|
|
163
|
+
throw new Error('Invalid runtime configuration preparation mode')
|
|
164
|
+
const base = new URL(directory, import.meta.url)
|
|
165
|
+
const incoming = readManifest(new URL('manifest.json', base))
|
|
166
|
+
const previous = mode === 'full'
|
|
167
|
+
? { metadata: {} }
|
|
168
|
+
: readManifest(previousManifest)
|
|
169
|
+
const before = mode === 'full'
|
|
170
|
+
? {}
|
|
171
|
+
: readEnvironment(previousEnvironment)
|
|
172
|
+
const next = readEnvironment(new URL('config/.env.runtime', base))
|
|
173
|
+
const entries = key => [
|
|
174
|
+
...new Set(`${previous.metadata[key] ?? ''}\n${incoming.metadata[key] ?? ''}`.split('\n').filter(Boolean)),
|
|
175
|
+
]
|
|
176
|
+
const keys = entries('managed-app-keys')
|
|
177
|
+
if (keys.some(key => !/^HAI_[A-Z0-9_]+$/.test(key)))
|
|
178
|
+
throw new Error('Invalid managed application environment key')
|
|
179
|
+
if (mode === 'partial') {
|
|
180
|
+
for (const key of keys) {
|
|
181
|
+
const retained = Object.keys(before).filter(name => name === key || name.startsWith(`${key}_`))
|
|
182
|
+
if (retained.length) {
|
|
183
|
+
for (const name of Object.keys(next)) {
|
|
184
|
+
if (name === key || name.startsWith(`${key}_`))
|
|
185
|
+
delete next[name]
|
|
186
|
+
}
|
|
187
|
+
for (const name of retained)
|
|
188
|
+
next[name] = before[name]
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const bindings = entries('managed-bindings').join('\n')
|
|
193
|
+
const state = readEnvironment(credentialState)
|
|
194
|
+
const project = incoming.metadata['managed-project'] ?? previous.metadata['managed-project']
|
|
195
|
+
if (state.PROJECT && project && state.PROJECT !== project.trim())
|
|
196
|
+
throw new Error('Managed middleware credential project mismatch')
|
|
197
|
+
applyManagedPasswords(next, bindings, state)
|
|
198
|
+
validateRefreshCookie(next)
|
|
199
|
+
incoming.metadata['managed-app-keys'] = `${keys.join('\n')}${keys.length ? '\n' : ''}`
|
|
200
|
+
incoming.metadata['managed-bindings'] = `${bindings}${bindings ? '\n' : ''}`
|
|
201
|
+
if (project)
|
|
202
|
+
incoming.metadata['managed-project'] = project
|
|
203
|
+
const environment = serialize(next)
|
|
204
|
+
if (Buffer.byteLength(environment) > maximumBytes)
|
|
205
|
+
throw new Error('Prepared runtime environment exceeds 1 MiB')
|
|
206
|
+
return { environment, manifest: incoming }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
210
|
+
if (process.argv[2] !== 'prepare' || process.argv.length !== 8)
|
|
211
|
+
throw new Error('Runtime preparation requires a release directory, previous manifest, current environment, credential state and mode')
|
|
212
|
+
process.stdout.write(JSON.stringify(prepare(...process.argv.slice(3))))
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
const values = readEnvironment('./config/.env.runtime')
|
|
216
|
+
for (const name of startupEnvironment)
|
|
217
|
+
delete values[name]
|
|
218
|
+
// HAI 配置的显式进程来源最高;镜像内 NODE_ENV/HOST/PORT 默认值由运行文件定稿。
|
|
219
|
+
const inherited = Object.fromEntries(Object.entries(process.env).filter(([name, value]) => name.startsWith('HAI_') && value !== undefined))
|
|
220
|
+
for (const name of Object.keys(inherited).sort((left, right) => left.length - right.length)) {
|
|
221
|
+
for (const previous of Object.keys(values)) {
|
|
222
|
+
// 与 Core 来源合并保持一致:独立开关只允许同名变量覆盖。
|
|
223
|
+
if (previous !== refreshCookieSecure && previous.startsWith(`${name}_`))
|
|
224
|
+
delete values[previous]
|
|
225
|
+
}
|
|
226
|
+
values[name] = inherited[name]
|
|
227
|
+
}
|
|
228
|
+
validateRefreshCookie(values)
|
|
229
|
+
Object.assign(process.env, values)
|
|
230
|
+
}
|