@h-ai/deploy 0.1.0-alpha.64 → 0.1.0-alpha.66
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 +46 -210
- package/README.ja.md +46 -210
- package/README.md +95 -193
- package/dist/index.d.ts +219 -27
- package/dist/index.js +1017 -625
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,15 +1,52 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'crypto';
|
|
2
|
+
import { createReadStream, readFileSync, existsSync, lstatSync, writeFileSync, chmodSync, mkdirSync, mkdtempSync, copyFileSync, rmSync, statSync, readdirSync } from 'fs';
|
|
3
|
+
import { resolve, join, basename, dirname, relative, sep } from 'path';
|
|
1
4
|
import { z } from 'zod';
|
|
2
|
-
import { core,
|
|
3
|
-
import
|
|
5
|
+
import { core, ok, err } from '@h-ai/core';
|
|
6
|
+
import process2 from 'process';
|
|
7
|
+
import { parseEnv } from 'util';
|
|
8
|
+
import { parse, stringify, parseAllDocuments } from 'yaml';
|
|
4
9
|
import { homedir } from 'os';
|
|
5
|
-
import {
|
|
6
|
-
import process from 'process';
|
|
7
|
-
import { parse, stringify } from 'yaml';
|
|
8
|
-
import { execSync, spawn } from 'child_process';
|
|
9
|
-
import { randomBytes, createHash } from 'crypto';
|
|
10
|
+
import { spawn } from 'child_process';
|
|
10
11
|
import { Buffer } from 'buffer';
|
|
11
12
|
|
|
12
|
-
// src/deploy-
|
|
13
|
+
// src/deploy-artifact.ts
|
|
14
|
+
var DeploymentArtifactSchema = z.object({
|
|
15
|
+
/** 清单格式版本,与应用版本和部署环境无关。 */
|
|
16
|
+
version: z.literal(2),
|
|
17
|
+
/** 应用根 package.json 的版本,用于归档、Compose 与桌面交付;最长 80 字符。 */
|
|
18
|
+
appVersion: z.string().max(80).regex(/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9a-z-]+(?:\.[0-9a-z-]+)*)?(?:\+[0-9a-z-]+(?:\.[0-9a-z-]+)*)?$/i),
|
|
19
|
+
/** 一次完整构建的标识,app/web 必须来自同一份清单。 */
|
|
20
|
+
buildId: z.string().regex(/^[a-z0-9][\w.+-]*$/i),
|
|
21
|
+
/** API/独立应用和可选 Web 的镜像归档,至少包含一个 app。 */
|
|
22
|
+
images: z.array(z.object({
|
|
23
|
+
/** app 为服务或独立应用,web 为同源 API 代理的静态前端。 */
|
|
24
|
+
name: z.enum(["app", "web"]),
|
|
25
|
+
/** 相对于清单目录的 tar 文件名。 */
|
|
26
|
+
file: z.string().regex(/^[\w.+-]+\.tar$/),
|
|
27
|
+
/** 归档导入后可直接使用的不可变镜像引用。 */
|
|
28
|
+
imageRef: z.string().regex(/^[a-z0-9][a-z0-9./:_-]+$/),
|
|
29
|
+
/** tar 内容的 SHA-256,用于部署前校验。 */
|
|
30
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
31
|
+
/** 容器内部监听端口;服务器公开端口由部署选择。 */
|
|
32
|
+
containerPort: z.number().int().min(1).max(65535).optional()
|
|
33
|
+
})).min(1).max(2).refine((images) => images.filter((image) => image.name === "app").length === 1 && new Set(images.map((image) => image.name)).size === images.length)
|
|
34
|
+
}).refine((artifact) => artifact.images.every((image) => image.file === `${image.name}-${artifact.appVersion}.tar`));
|
|
35
|
+
async function artifactChecksum(file) {
|
|
36
|
+
const hash = createHash("sha256");
|
|
37
|
+
for await (const chunk of createReadStream(file))
|
|
38
|
+
hash.update(chunk);
|
|
39
|
+
return hash.digest("hex");
|
|
40
|
+
}
|
|
41
|
+
async function readDeploymentArtifact(directory) {
|
|
42
|
+
const artifact = DeploymentArtifactSchema.parse(JSON.parse(readFileSync(resolve(directory, "manifest.json"), "utf8")));
|
|
43
|
+
for (const image of artifact.images) {
|
|
44
|
+
const file = resolve(directory, image.file);
|
|
45
|
+
if (!existsSync(file) || !lstatSync(file).isFile() || lstatSync(file).isSymbolicLink() || await artifactChecksum(file) !== image.sha256)
|
|
46
|
+
throw new Error(`Invalid artifact: ${image.file}`);
|
|
47
|
+
}
|
|
48
|
+
return artifact;
|
|
49
|
+
}
|
|
13
50
|
|
|
14
51
|
// messages/en-US.json
|
|
15
52
|
var en_US_default = {
|
|
@@ -21,7 +58,6 @@ var en_US_default = {
|
|
|
21
58
|
deploy_authRequired: "Authentication required",
|
|
22
59
|
deploy_authFailed: "Authentication failed: {error}",
|
|
23
60
|
deploy_projectCreateFailed: "Project creation failed: {error}",
|
|
24
|
-
deploy_buildFailed: "Application build failed: {error}",
|
|
25
61
|
deploy_uploadFailed: "Build output upload failed: {error}",
|
|
26
62
|
deploy_deployFailed: "Deployment failed: {error}",
|
|
27
63
|
deploy_provisionFailed: "Service provisioning failed: {service} - {error}",
|
|
@@ -41,12 +77,33 @@ var en_US_default = {
|
|
|
41
77
|
deploy_recoverySuggestion: "Inspect listed resources in the provider console before retrying with the same project name. Unknown means the request may have created a resource. Reused resources are never deleted automatically; remove newly created resources manually only after checking their use.",
|
|
42
78
|
deploy_commandTimeout: "Command timed out: {command}",
|
|
43
79
|
deploy_commandFailed: "Command failed ({command}): {error}",
|
|
44
|
-
deploy_containerRuntimeNotFound: "No usable container runtime found: {runtime}",
|
|
45
|
-
deploy_containerBuildFailed: "Container image build failed: {error}",
|
|
46
80
|
deploy_remoteConnectFailed: "Cannot connect to remote host {host}: {error}",
|
|
47
81
|
deploy_remoteRuntimeMissing: "Remote host missing container runtime or compose: {runtime}",
|
|
48
82
|
deploy_imageTransferFailed: "Image transfer failed: {error}",
|
|
49
|
-
deploy_healthCheckFailed: "Health check failed, app not ready at {url}"
|
|
83
|
+
deploy_healthCheckFailed: "Health check failed, app not ready at {url}",
|
|
84
|
+
deploy_invalidEnvironment: "Use letters, numbers, underscores or hyphens for the environment; local/example are reserved.",
|
|
85
|
+
deploy_environmentMissing: "Environment file missing: {path}. Create and configure it before deploying.",
|
|
86
|
+
deploy_environmentReadFailed: "Cannot read deployment environment files; check syntax and permissions.",
|
|
87
|
+
deploy_environmentWriteFailed: "Cannot save deployment configuration; check file syntax and permissions.",
|
|
88
|
+
deploy_multipleApplications: "Multiple service packages found; specify the application directory.",
|
|
89
|
+
deploy_purposeSqlite: "Local relational database persisted in a volume",
|
|
90
|
+
deploy_purposeDatabase: "Relational database service for business data",
|
|
91
|
+
deploy_purposeMemory: "Per-process memory cache; cleared on restart, no external service",
|
|
92
|
+
deploy_purposeLocal: "Local filesystem storage persisted in a volume",
|
|
93
|
+
deploy_purposeCache: "Shared cache, sessions and distributed locks",
|
|
94
|
+
deploy_purposeStorage: "S3-compatible object storage for uploaded files",
|
|
95
|
+
deploy_purposeVector: "Vector indexing and similarity search",
|
|
96
|
+
deploy_purposeDisabled: "LDAP login is disabled; no directory service is created",
|
|
97
|
+
deploy_purposeLdap: "Directory account authentication; the default directory has no business users",
|
|
98
|
+
deploy_purposeConsole: "Messages go to application logs without delivery",
|
|
99
|
+
deploy_purposeEmail: "Email/HTTP delivery; the default SMTP container only captures test messages",
|
|
100
|
+
deploy_purposeSms: "SMS through Aliyun credentials; requires an existing signature and quota",
|
|
101
|
+
deploy_webProxyInvalid: "The API prefix must be an absolute path containing only letters, numbers, underscores, hyphens and slashes.",
|
|
102
|
+
deploy_artifactInvalid: "Build artifacts are missing, corrupt, or could not be packaged. Run pnpm build --target docker at the project root and point --artifact-dir to a complete build directory.",
|
|
103
|
+
deploy_sshRequired: "Automatic deployment requires SSH settings. Use --bundle-only for manual transfer when the server is unreachable.",
|
|
104
|
+
deploy_bundleInstructions: "This bundle contains private configuration. Transfer it securely and restrict access.\n1. Copy {file} to Linux using removable media, a private network or scp.\n2. Install Docker/Podman with compose, tar, sha256sum and sh. Load middleware images using docker load/podman load with the exact tags below.\n3. Create a private release directory: mkdir -p release && chmod 700 release\n4. From the bundle directory run tar -xf {file} -C release\n5. Run cd release && sh {start}. Set HAI_DEPLOY_PROVIDER_REMOTE_RUNTIME=docker or podman to select the runtime explicitly.\n6. Check docker compose -f {compose} --env-file .env.runtime ps (or logs; use podman instead of docker for Podman), Web and /health after startup. Bundle generation does not perform server health checks.\nRequired images (app/web are included; prepare middleware separately):\n{images}\nBuilt images can be reused across environments; this bundle contains configuration for the selected environment only.",
|
|
105
|
+
deploy_bundleMetadata: "Application version: {version}\nBuild ID: {buildId}\nCompose file: {compose}",
|
|
106
|
+
deploy_bundleOnlyContainer: "Compose bundle generation is supported only by docker-ssh."
|
|
50
107
|
};
|
|
51
108
|
|
|
52
109
|
// messages/zh-CN.json
|
|
@@ -59,7 +116,6 @@ var zh_CN_default = {
|
|
|
59
116
|
deploy_authRequired: "\u8BF7\u5148\u5B8C\u6210\u8BA4\u8BC1",
|
|
60
117
|
deploy_authFailed: "\u8BA4\u8BC1\u5931\u8D25\uFF1A{error}",
|
|
61
118
|
deploy_projectCreateFailed: "\u9879\u76EE\u521B\u5EFA\u5931\u8D25\uFF1A{error}",
|
|
62
|
-
deploy_buildFailed: "\u5E94\u7528\u6784\u5EFA\u5931\u8D25\uFF1A{error}",
|
|
63
119
|
deploy_uploadFailed: "\u6784\u5EFA\u4EA7\u7269\u4E0A\u4F20\u5931\u8D25\uFF1A{error}",
|
|
64
120
|
deploy_deployFailed: "\u90E8\u7F72\u5931\u8D25\uFF1A{error}",
|
|
65
121
|
deploy_provisionFailed: "\u57FA\u7840\u8BBE\u65BD\u5F00\u901A\u5931\u8D25\uFF1A{service} - {error}",
|
|
@@ -79,12 +135,33 @@ var zh_CN_default = {
|
|
|
79
135
|
deploy_recoverySuggestion: "\u91CD\u8BD5\u524D\u5728\u670D\u52A1\u5546\u63A7\u5236\u53F0\u6838\u5BF9\u6E05\u5355\uFF0C\u5E76\u4F7F\u7528\u76F8\u540C\u9879\u76EE\u540D\u3002unknown \u8868\u793A\u8BF7\u6C42\u53EF\u80FD\u5DF2\u521B\u5EFA\u8D44\u6E90\u3002\u590D\u7528\u8D44\u6E90\u4E0D\u4F1A\u81EA\u52A8\u5220\u9664\uFF1B\u65B0\u5EFA\u8D44\u6E90\u4EC5\u5728\u786E\u8BA4\u672A\u88AB\u4F7F\u7528\u540E\u4EBA\u5DE5\u6E05\u7406\u3002",
|
|
80
136
|
deploy_commandTimeout: "\u547D\u4EE4\u6267\u884C\u8D85\u65F6\uFF1A{command}",
|
|
81
137
|
deploy_commandFailed: "\u547D\u4EE4\u6267\u884C\u5931\u8D25\uFF08{command}\uFF09\uFF1A{error}",
|
|
82
|
-
deploy_containerRuntimeNotFound: "\u672A\u627E\u5230\u53EF\u7528\u7684\u5BB9\u5668\u8FD0\u884C\u65F6\uFF1A{runtime}",
|
|
83
|
-
deploy_containerBuildFailed: "\u5BB9\u5668\u955C\u50CF\u6784\u5EFA\u5931\u8D25\uFF1A{error}",
|
|
84
138
|
deploy_remoteConnectFailed: "\u65E0\u6CD5\u8FDE\u63A5\u8FDC\u7A0B\u4E3B\u673A {host}\uFF1A{error}",
|
|
85
139
|
deploy_remoteRuntimeMissing: "\u8FDC\u7A0B\u4E3B\u673A\u7F3A\u5C11\u5BB9\u5668\u8FD0\u884C\u65F6\u6216 compose\uFF1A{runtime}",
|
|
86
140
|
deploy_imageTransferFailed: "\u955C\u50CF\u4F20\u8F93\u5931\u8D25\uFF1A{error}",
|
|
87
|
-
deploy_healthCheckFailed: "\u5065\u5EB7\u68C0\u67E5\u5931\u8D25\uFF0C\u5E94\u7528\u672A\u5728 {url} \u5C31\u7EEA"
|
|
141
|
+
deploy_healthCheckFailed: "\u5065\u5EB7\u68C0\u67E5\u5931\u8D25\uFF0C\u5E94\u7528\u672A\u5728 {url} \u5C31\u7EEA",
|
|
142
|
+
deploy_invalidEnvironment: "\u73AF\u5883\u540D\u79F0\u4EC5\u5141\u8BB8\u5B57\u6BCD\u3001\u6570\u5B57\u3001\u4E0B\u5212\u7EBF\u548C\u8FDE\u5B57\u7B26\uFF0C\u4E14\u4E0D\u80FD\u4F7F\u7528 local/example\u3002",
|
|
143
|
+
deploy_environmentMissing: "\u7F3A\u5C11\u73AF\u5883\u914D\u7F6E {path}\uFF1B\u8BF7\u5148\u521B\u5EFA\u8BE5\u6587\u4EF6\u5E76\u5B8C\u6210\u914D\u7F6E\uFF0C\u518D\u91CD\u65B0\u90E8\u7F72\u3002",
|
|
144
|
+
deploy_environmentReadFailed: "\u90E8\u7F72\u73AF\u5883\u6587\u4EF6\u8BFB\u53D6\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u6587\u4EF6\u683C\u5F0F\u4E0E\u6743\u9650\u3002",
|
|
145
|
+
deploy_environmentWriteFailed: "\u90E8\u7F72\u914D\u7F6E\u5199\u5165\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u6587\u4EF6\u683C\u5F0F\u4E0E\u6743\u9650\u3002",
|
|
146
|
+
deploy_multipleApplications: "\u53D1\u73B0\u591A\u4E2A\u670D\u52A1\u5305\uFF0C\u8BF7\u6307\u5B9A\u8981\u90E8\u7F72\u7684\u5E94\u7528\u76EE\u5F55\u3002",
|
|
147
|
+
deploy_purposeSqlite: "\u672C\u5730\u6587\u4EF6\u5173\u7CFB\u6570\u636E\u5E93\uFF0C\u4F7F\u7528\u6301\u4E45\u5316\u5377",
|
|
148
|
+
deploy_purposeDatabase: "\u72EC\u7ACB\u5173\u7CFB\u6570\u636E\u5E93\u670D\u52A1\uFF0C\u4FDD\u5B58\u4E1A\u52A1\u6570\u636E",
|
|
149
|
+
deploy_purposeMemory: "\u5355\u8FDB\u7A0B\u5185\u5B58\u7F13\u5B58\uFF0C\u91CD\u542F\u540E\u6E05\u7A7A\uFF0C\u4E0D\u521B\u5EFA\u5916\u90E8\u670D\u52A1",
|
|
150
|
+
deploy_purposeLocal: "\u672C\u5730\u6587\u4EF6\u5B58\u50A8\uFF0C\u4F7F\u7528\u6301\u4E45\u5316\u5377",
|
|
151
|
+
deploy_purposeCache: "\u5171\u4EAB\u7F13\u5B58\u3001\u4F1A\u8BDD\u4E0E\u5206\u5E03\u5F0F\u9501",
|
|
152
|
+
deploy_purposeStorage: "S3 \u517C\u5BB9\u5BF9\u8C61\u5B58\u50A8\uFF0C\u4FDD\u5B58\u4E0A\u4F20\u6587\u4EF6",
|
|
153
|
+
deploy_purposeVector: "\u5411\u91CF\u7D22\u5F15\u4E0E\u76F8\u4F3C\u5EA6\u641C\u7D22",
|
|
154
|
+
deploy_purposeDisabled: "LDAP \u767B\u5F55\u672A\u542F\u7528\uFF0C\u4E0D\u521B\u5EFA\u76EE\u5F55\u670D\u52A1",
|
|
155
|
+
deploy_purposeLdap: "\u4F01\u4E1A\u76EE\u5F55\u8D26\u53F7\u8BA4\u8BC1\uFF1B\u9ED8\u8BA4\u76EE\u5F55\u4E0D\u5305\u542B\u4E1A\u52A1\u7528\u6237",
|
|
156
|
+
deploy_purposeConsole: "\u6D88\u606F\u8F93\u51FA\u5230\u5E94\u7528\u65E5\u5FD7\uFF0C\u4E0D\u5B9E\u9645\u6295\u9012",
|
|
157
|
+
deploy_purposeEmail: "\u90AE\u4EF6\u6216 HTTP \u901A\u77E5\u6295\u9012\uFF1B\u9ED8\u8BA4 SMTP \u5BB9\u5668\u4EC5\u7528\u4E8E\u6D4B\u8BD5\u63A5\u6536",
|
|
158
|
+
deploy_purposeSms: "\u901A\u8FC7\u963F\u91CC\u4E91\u51ED\u8BC1\u63A5\u5165\u77ED\u4FE1\u670D\u52A1\uFF0C\u9700\u5DF2\u6709\u7B7E\u540D\u4E0E\u989D\u5EA6",
|
|
159
|
+
deploy_webProxyInvalid: "API \u524D\u7F00\u5FC5\u987B\u662F\u4EC5\u542B\u5B57\u6BCD\u3001\u6570\u5B57\u3001\u4E0B\u5212\u7EBF\u3001\u8FDE\u5B57\u7B26\u548C\u659C\u6760\u7684\u7EDD\u5BF9\u8DEF\u5F84\u3002",
|
|
160
|
+
deploy_artifactInvalid: "\u6784\u5EFA\u4EA7\u7269\u7F3A\u5931\u3001\u635F\u574F\u6216\u90E8\u7F72\u6253\u5305\u5931\u8D25\u3002\u8BF7\u5148\u5728\u5DE5\u7A0B\u6839\u8FD0\u884C pnpm build --target docker\uFF0C\u5E76\u68C0\u67E5 --artifact-dir \u6307\u5411\u5B8C\u6574\u7684\u6784\u5EFA\u76EE\u5F55\u3002",
|
|
161
|
+
deploy_sshRequired: "\u81EA\u52A8\u90E8\u7F72\u9700\u8981 SSH \u914D\u7F6E\uFF1B\u65E0\u7F51\u7EDC\u8FDE\u63A5\u65F6\u4F7F\u7528 --bundle-only \u751F\u6210\u90E8\u7F72\u5305\u5E76\u624B\u5DE5\u642C\u8FD0\u3002",
|
|
162
|
+
deploy_bundleInstructions: "\u6B64\u5305\u5305\u542B\u79C1\u6709\u914D\u7F6E\uFF0C\u8BF7\u5B89\u5168\u642C\u8FD0\u5E76\u9650\u5236\u8BBF\u95EE\u6743\u9650\u3002\n1. \u5C06 {file} \u590D\u5236\u5230 Linux \u670D\u52A1\u5668\uFF08U \u76D8\u3001\u5185\u7F51\u4F20\u8F93\u6216 scp \u5747\u53EF\uFF09\u3002\n2. \u51C6\u5907 Docker/Podman\u3001\u5BF9\u5E94 compose \u547D\u4EE4\u3001tar\u3001sha256sum \u548C sh\uFF1B\u5C06\u6240\u9700\u4E2D\u95F4\u4EF6\u955C\u50CF\u7528 docker load/podman load \u5BFC\u5165\uFF0C\u955C\u50CF\u6807\u7B7E\u987B\u4E0E\u4E0B\u5217\u6E05\u5355\u5B8C\u5168\u4E00\u81F4\u3002\n3. \u65B0\u5EFA\u4EC5\u90E8\u7F72\u7528\u6237\u53EF\u8BBF\u95EE\u7684\u76EE\u5F55\uFF1Amkdir -p release && chmod 700 release\n4. \u5728\u5305\u6240\u5728\u76EE\u5F55\u6267\u884C tar -xf {file} -C release\n5. \u6267\u884C cd release && sh {start}\uFF1B\u53EF\u7528 HAI_DEPLOY_PROVIDER_REMOTE_RUNTIME=docker \u6216 podman \u663E\u5F0F\u9009\u62E9\u8FD0\u884C\u65F6\u3002\n6. \u542F\u52A8\u540E\u7528 docker compose -f {compose} --env-file .env.runtime ps\uFF08\u6216 logs\uFF1BPodman \u5C06 docker \u6362\u4E3A podman\uFF09\u68C0\u67E5\u72B6\u6001\u5E76\u9A8C\u8BC1 Web\u3001/health\uFF1B\u4EC5\u6253\u5305\u4E0D\u4F1A\u6267\u884C\u670D\u52A1\u5668\u5065\u5EB7\u68C0\u67E5\u3002\n\u6240\u9700\u955C\u50CF\uFF08app/web \u5DF2\u542B\u5728\u5305\u4E2D\uFF0C\u4E2D\u95F4\u4EF6\u9700\u53E6\u884C\u51C6\u5907\uFF09\uFF1A\n{images}\n\u6784\u5EFA\u955C\u50CF\u53EF\u7528\u4E8E\u591A\u73AF\u5883\uFF1B\u6B64\u5305\u7684\u73AF\u5883\u914D\u7F6E\u4EC5\u7528\u4E8E\u672C\u6B21\u6240\u9009\u73AF\u5883\u3002",
|
|
163
|
+
deploy_bundleMetadata: "\u5E94\u7528\u7248\u672C\uFF1A{version}\n\u6784\u5EFA\u7F16\u53F7\uFF1A{buildId}\nCompose \u6587\u4EF6\uFF1A{compose}",
|
|
164
|
+
deploy_bundleOnlyContainer: "\u4EC5\u751F\u6210 Compose tar \u53EA\u652F\u6301 docker-ssh\u3002"
|
|
88
165
|
};
|
|
89
166
|
|
|
90
167
|
// src/deploy-i18n.ts
|
|
@@ -117,15 +194,15 @@ var SshConfigSchema = z.object({
|
|
|
117
194
|
var PortExposeSchema = z.object({
|
|
118
195
|
type: z.literal("port").default("port"),
|
|
119
196
|
/** 容器内监听端口(adapter-node 默认 3000) */
|
|
120
|
-
containerPort: z.number().int().
|
|
197
|
+
containerPort: z.number().int().min(1).max(65535).default(3e3),
|
|
121
198
|
/** 宿主机映射端口 */
|
|
122
|
-
hostPort: z.number().int().
|
|
199
|
+
hostPort: z.number().int().min(1).max(65535)
|
|
123
200
|
});
|
|
124
201
|
var ExposeSchema = PortExposeSchema;
|
|
125
202
|
var DockerSshProviderConfigSchema = z.object({
|
|
126
203
|
type: z.literal("docker-ssh"),
|
|
127
204
|
/** SSH 连接参数 */
|
|
128
|
-
ssh: SshConfigSchema,
|
|
205
|
+
ssh: SshConfigSchema.optional(),
|
|
129
206
|
/** 远程主机运行参数 */
|
|
130
207
|
remote: z.object({
|
|
131
208
|
/** 远程应用根目录 */
|
|
@@ -175,17 +252,19 @@ var ServicesConfigSchema = z.object({
|
|
|
175
252
|
sms: z.discriminatedUnion("provisioner", [AliyunSmsServiceSchema]).optional()
|
|
176
253
|
}).optional();
|
|
177
254
|
var DeployConfigSchema = z.object({
|
|
255
|
+
/** 部署环境文件模式;dev/test/prod 或自定义模式,禁止 local/example。 */
|
|
256
|
+
environment: z.string().regex(/^[a-z0-9][\w-]*$/i).refine((value) => !["local", "example"].includes(value.toLowerCase())).optional(),
|
|
178
257
|
provider: ProviderConfigSchema,
|
|
179
258
|
services: ServicesConfigSchema,
|
|
180
|
-
/**
|
|
259
|
+
/** Docker Compose 部署参数;构建配置位于项目 hai.build.json。 */
|
|
181
260
|
container: z.object({
|
|
182
|
-
|
|
261
|
+
/** 自建 S3 服务实现;协议仍为 storage.type=s3。 */
|
|
262
|
+
s3Engine: z.enum(["rustfs", "minio"]).default("rustfs")
|
|
183
263
|
}).optional()
|
|
184
264
|
});
|
|
185
265
|
var DeployErrorInfo = {
|
|
186
266
|
DEPLOY_FAILED: "001:500",
|
|
187
267
|
PROJECT_CREATE_FAILED: "002:500",
|
|
188
|
-
BUILD_FAILED: "003:500",
|
|
189
268
|
UPLOAD_FAILED: "004:500",
|
|
190
269
|
AUTH_REQUIRED: "005:401",
|
|
191
270
|
AUTH_FAILED: "006:401",
|
|
@@ -197,8 +276,6 @@ var DeployErrorInfo = {
|
|
|
197
276
|
UNSUPPORTED_TYPE: "012:400",
|
|
198
277
|
CONFIG_ERROR: "013:500",
|
|
199
278
|
CREDENTIAL_ERROR: "014:500",
|
|
200
|
-
CONTAINER_RUNTIME_NOT_FOUND: "015:500",
|
|
201
|
-
CONTAINER_BUILD_FAILED: "016:500",
|
|
202
279
|
REMOTE_CONNECT_FAILED: "017:500",
|
|
203
280
|
REMOTE_COMMAND_FAILED: "018:500",
|
|
204
281
|
IMAGE_TRANSFER_FAILED: "019:500",
|
|
@@ -206,7 +283,172 @@ var DeployErrorInfo = {
|
|
|
206
283
|
};
|
|
207
284
|
var HaiDeployError = core.error.buildHaiErrorsDef("deploy", DeployErrorInfo);
|
|
208
285
|
|
|
209
|
-
// src/deploy-
|
|
286
|
+
// src/deploy-environment.ts
|
|
287
|
+
function environmentValue(value) {
|
|
288
|
+
try {
|
|
289
|
+
return parse(value) ?? value;
|
|
290
|
+
} catch {
|
|
291
|
+
return value;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function resolveConfig(value, prefix, env) {
|
|
295
|
+
if (env[prefix] !== void 0)
|
|
296
|
+
return environmentValue(env[prefix]);
|
|
297
|
+
if (Array.isArray(value))
|
|
298
|
+
return value.map((item, index) => resolveConfig(item, `${prefix}_${index}`, env));
|
|
299
|
+
if (value && typeof value === "object")
|
|
300
|
+
return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, resolveConfig(nested, `${prefix}_${key.toUpperCase()}`, env)]));
|
|
301
|
+
if (typeof value !== "string")
|
|
302
|
+
return value;
|
|
303
|
+
const resolved = value.replace(/\$\{([^:}]+)(?::([^}]*))?\}/g, (_match, name, fallback) => env[name] ?? fallback ?? "");
|
|
304
|
+
return /^\$\{[^}]+\}$/.test(value) ? environmentValue(resolved) : resolved;
|
|
305
|
+
}
|
|
306
|
+
function loadDeploymentConfig(name, path, values) {
|
|
307
|
+
try {
|
|
308
|
+
const config = resolveConfig(parse(readFileSync(path, "utf8")), `HAI_${name.toUpperCase()}`, values);
|
|
309
|
+
return ok(config && typeof config === "object" && !Array.isArray(config) ? config : {});
|
|
310
|
+
} catch {
|
|
311
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_environmentReadFailed"));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function loadDeploymentEnvironment(options) {
|
|
315
|
+
const environment = options.environment === "production" ? "prod" : options.environment === "development" ? "dev" : options.environment;
|
|
316
|
+
if (!/^[a-z0-9][\w-]*$/i.test(environment) || ["local", "example"].includes(environment.toLowerCase()))
|
|
317
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_invalidEnvironment"));
|
|
318
|
+
const envDir = resolve(options.envDir);
|
|
319
|
+
const target = join(envDir, `.env.${environment}`);
|
|
320
|
+
if (options.requireFile !== false && !existsSync(target))
|
|
321
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_environmentMissing", { params: { path: target } }));
|
|
322
|
+
const loaded = core.config.loadEnvFiles({ mode: environment, envDir });
|
|
323
|
+
if (!loaded.success)
|
|
324
|
+
return loaded;
|
|
325
|
+
try {
|
|
326
|
+
const values = { ...loaded.data };
|
|
327
|
+
for (const [name, value] of Object.entries(process2.env)) {
|
|
328
|
+
if (/^(?:HAI_|PUBLIC_|VITE_)/.test(name) && value !== void 0)
|
|
329
|
+
values[name] = value;
|
|
330
|
+
}
|
|
331
|
+
values.HAI_DEPLOY_ENVIRONMENT = environment;
|
|
332
|
+
values.HAI_CORE_ENV ??= environment === "prod" ? "production" : environment === "dev" ? "development" : environment === "test" ? "test" : "staging";
|
|
333
|
+
const targetValues = {};
|
|
334
|
+
if (existsSync(target)) {
|
|
335
|
+
for (const [name, value] of Object.entries(parseEnv(readFileSync(target, "utf8")))) {
|
|
336
|
+
if (value !== void 0)
|
|
337
|
+
targetValues[name] = value;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return ok({ environment, envDir, values, targetValues });
|
|
341
|
+
} catch {
|
|
342
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_environmentReadFailed"));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
function isSecret(name, value) {
|
|
346
|
+
if (/PASSWORD|SECRET|TOKEN|APIKEY|PRIVATEKEY|ACCESSKEY|AUTHORIZATION/i.test(name.replace(/[^a-z0-9]/gi, "")) || /(?:^|_)PASS$/i.test(name) || value.includes("PRIVATE KEY"))
|
|
347
|
+
return true;
|
|
348
|
+
try {
|
|
349
|
+
const url = new URL(value);
|
|
350
|
+
if (url.username || url.password || [...url.searchParams.keys()].some((key) => /password|secret|token|key|authorization/i.test(key)))
|
|
351
|
+
return true;
|
|
352
|
+
} catch {
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
const parsed = JSON.parse(value);
|
|
356
|
+
if (parsed && typeof parsed === "object")
|
|
357
|
+
return Object.entries(parsed).some(([key, nested]) => isSecret(key, typeof nested === "string" ? nested : JSON.stringify(nested)));
|
|
358
|
+
} catch {
|
|
359
|
+
}
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
function publicConfig(value) {
|
|
363
|
+
if (Array.isArray(value))
|
|
364
|
+
return value.map(publicConfig);
|
|
365
|
+
if (value && typeof value === "object")
|
|
366
|
+
return Object.fromEntries(Object.entries(value).filter(([name, item]) => !isSecret(name, typeof item === "string" ? item : "")).map(([name, item]) => [name, publicConfig(item)]));
|
|
367
|
+
return value;
|
|
368
|
+
}
|
|
369
|
+
function quoteEnv(value) {
|
|
370
|
+
try {
|
|
371
|
+
const parsed = JSON.parse(value);
|
|
372
|
+
if (parsed && typeof parsed === "object")
|
|
373
|
+
return `'${JSON.stringify(parsed).replaceAll("'", "\\u0027")}'`;
|
|
374
|
+
} catch {
|
|
375
|
+
}
|
|
376
|
+
if (!value.includes("'"))
|
|
377
|
+
return `'${value}'`;
|
|
378
|
+
if (!value.includes('"'))
|
|
379
|
+
return `"${value}"`;
|
|
380
|
+
if (!value.includes("`"))
|
|
381
|
+
return `\`${value}\``;
|
|
382
|
+
if (!/[\r\n#]/.test(value))
|
|
383
|
+
return value;
|
|
384
|
+
throw new Error("Unsupported dotenv value");
|
|
385
|
+
}
|
|
386
|
+
function updateFile(path, values, privateFile) {
|
|
387
|
+
let content = existsSync(path) ? readFileSync(path, "utf8") : "# Local private deployment configuration; do not commit.\n";
|
|
388
|
+
for (const [name, value] of Object.entries(values)) {
|
|
389
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(name))
|
|
390
|
+
throw new Error("Invalid environment variable name");
|
|
391
|
+
const line = `${name}=${quoteEnv(value)}`;
|
|
392
|
+
const pattern = new RegExp(`^(?:export\\s+)?${name}\\s*=\\s*(?:'[^']*'|"[^"]*"|\`[^\`]*\`|[^\\r\\n]*)[^\\r\\n]*`, "gm");
|
|
393
|
+
if (pattern.test(content))
|
|
394
|
+
content = content.replace(pattern, () => line);
|
|
395
|
+
else
|
|
396
|
+
content = `${content.trimEnd()}
|
|
397
|
+
${line}
|
|
398
|
+
`;
|
|
399
|
+
}
|
|
400
|
+
writeFileSync(path, content, { mode: privateFile ? 384 : 420 });
|
|
401
|
+
if (privateFile)
|
|
402
|
+
chmodSync(path, 384);
|
|
403
|
+
}
|
|
404
|
+
function removeOverrides(path, names, privateFile = true) {
|
|
405
|
+
if (!existsSync(path) || names.length === 0)
|
|
406
|
+
return;
|
|
407
|
+
let content = readFileSync(path, "utf8");
|
|
408
|
+
for (const name of names)
|
|
409
|
+
content = content.replace(new RegExp(`^(?:export\\s+)?${name}\\s*=\\s*(?:'[^']*'|"[^"]*"|\`[^\`]*\`|[^\\r\\n]*)[^\\r\\n]*(?:\\r?\\n|$)`, "gm"), "");
|
|
410
|
+
writeFileSync(path, content, { mode: privateFile ? 384 : 420 });
|
|
411
|
+
chmodSync(path, privateFile ? 384 : 420);
|
|
412
|
+
}
|
|
413
|
+
function saveDeploymentEnvironment(environment, values, options = {}) {
|
|
414
|
+
try {
|
|
415
|
+
const shared = {};
|
|
416
|
+
const privateValues = {};
|
|
417
|
+
for (const [name, value] of Object.entries(values)) {
|
|
418
|
+
if (options.private) {
|
|
419
|
+
privateValues[name] = value;
|
|
420
|
+
} else if (isSecret(name, value)) {
|
|
421
|
+
privateValues[name] = value;
|
|
422
|
+
try {
|
|
423
|
+
const parsed = JSON.parse(value);
|
|
424
|
+
if (parsed && typeof parsed === "object")
|
|
425
|
+
shared[name] = JSON.stringify(publicConfig(parsed));
|
|
426
|
+
} catch {
|
|
427
|
+
}
|
|
428
|
+
} else {
|
|
429
|
+
shared[name] = value;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (Object.keys(shared).length)
|
|
433
|
+
updateFile(join(environment.envDir, `.env.${environment.environment}`), shared, false);
|
|
434
|
+
if (Object.keys(privateValues).length)
|
|
435
|
+
updateFile(join(environment.envDir, ".env.local"), privateValues, true);
|
|
436
|
+
removeOverrides(join(environment.envDir, `.env.${environment.environment}`), Object.keys(privateValues).filter((name) => !(name in shared)), false);
|
|
437
|
+
removeOverrides(join(environment.envDir, ".env.local"), Object.keys(shared).filter((name) => !(name in privateValues)));
|
|
438
|
+
if (Object.keys(privateValues).length) {
|
|
439
|
+
const ignorePath = join(environment.envDir, ".gitignore");
|
|
440
|
+
const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : "";
|
|
441
|
+
if (!ignore.split(/\r?\n/).includes(".env.local"))
|
|
442
|
+
writeFileSync(ignorePath, `${ignore.trimEnd()}
|
|
443
|
+
.env.local
|
|
444
|
+
.hai/deploy/
|
|
445
|
+
`);
|
|
446
|
+
}
|
|
447
|
+
return ok(Object.keys(values));
|
|
448
|
+
} catch {
|
|
449
|
+
return err(HaiDeployError.ENV_VAR_FAILED, deployM("deploy_environmentWriteFailed"));
|
|
450
|
+
}
|
|
451
|
+
}
|
|
210
452
|
var logger = core.logger.child({ module: "deploy", scope: "credentials" });
|
|
211
453
|
var DEPLOY_CREDENTIAL_KEY_PATTERN = /^HAI_DEPLOY_[A-Z0-9_]+$/;
|
|
212
454
|
function isAllowedCredentialKey(key) {
|
|
@@ -270,7 +512,7 @@ function loadCredentials() {
|
|
|
270
512
|
const data = readCredentialsFile();
|
|
271
513
|
const keys = Object.keys(data);
|
|
272
514
|
for (const key of keys) {
|
|
273
|
-
|
|
515
|
+
process2.env[key] = String(data[key]);
|
|
274
516
|
}
|
|
275
517
|
logger.debug("Credentials loaded", { count: keys.length });
|
|
276
518
|
return ok(keys);
|
|
@@ -291,7 +533,7 @@ function saveCredential(key, value) {
|
|
|
291
533
|
const data = readCredentialsFile();
|
|
292
534
|
data[key] = value;
|
|
293
535
|
writeCredentialsFile(data);
|
|
294
|
-
|
|
536
|
+
process2.env[key] = value;
|
|
295
537
|
logger.debug("Credential saved", { key });
|
|
296
538
|
return ok(void 0);
|
|
297
539
|
} catch (error) {
|
|
@@ -311,7 +553,7 @@ function saveCredentials(entries) {
|
|
|
311
553
|
const data = readCredentialsFile();
|
|
312
554
|
for (const [key, value] of Object.entries(entries)) {
|
|
313
555
|
data[key] = value;
|
|
314
|
-
|
|
556
|
+
process2.env[key] = value;
|
|
315
557
|
}
|
|
316
558
|
writeCredentialsFile(data);
|
|
317
559
|
logger.debug("Credentials saved", { count: Object.keys(entries).length });
|
|
@@ -327,35 +569,25 @@ function saveCredentials(entries) {
|
|
|
327
569
|
);
|
|
328
570
|
}
|
|
329
571
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
return ok(void 0);
|
|
340
|
-
} catch (error) {
|
|
341
|
-
return err(
|
|
342
|
-
HaiDeployError.BUILD_FAILED,
|
|
343
|
-
deployM("deploy_buildFailed", {
|
|
344
|
-
params: { error: error instanceof Error ? error.message : String(error) }
|
|
345
|
-
}),
|
|
346
|
-
error
|
|
347
|
-
);
|
|
572
|
+
function resolveArtifactDirectory(appDir) {
|
|
573
|
+
let directory = resolve(appDir);
|
|
574
|
+
while (true) {
|
|
575
|
+
if (existsSync(join(directory, "hai.build.json")) || existsSync(join(directory, "pnpm-lock.yaml")))
|
|
576
|
+
return join(directory, ".hai/build/docker");
|
|
577
|
+
const parent = dirname(directory);
|
|
578
|
+
if (parent === directory)
|
|
579
|
+
return join(appDir, ".hai/build/docker");
|
|
580
|
+
directory = parent;
|
|
348
581
|
}
|
|
349
582
|
}
|
|
583
|
+
function usesDefaultMiddleware(scan, service, options) {
|
|
584
|
+
const requirements = scan.middlewares?.filter((item) => item.service === service) ?? [];
|
|
585
|
+
return requirements.length ? requirements.some((item) => (options?.serviceModes?.[item.envPrefix] ?? options?.serviceModes?.[service] ?? "default") === "default") : (options?.serviceModes?.[service] ?? "default") === "default";
|
|
586
|
+
}
|
|
350
587
|
function resolveOutputDir(appDir, providerType) {
|
|
351
588
|
switch (providerType) {
|
|
352
|
-
case "vercel":
|
|
353
|
-
|
|
354
|
-
if (existsSync(vercelOutput)) {
|
|
355
|
-
return vercelOutput;
|
|
356
|
-
}
|
|
357
|
-
return join(appDir, "build");
|
|
358
|
-
}
|
|
589
|
+
case "vercel":
|
|
590
|
+
return join(appDir, ".vercel", "output");
|
|
359
591
|
default:
|
|
360
592
|
return join(appDir, "build");
|
|
361
593
|
}
|
|
@@ -386,196 +618,300 @@ function recoveryFailure(error, recovery) {
|
|
|
386
618
|
ext: { recovery }
|
|
387
619
|
});
|
|
388
620
|
}
|
|
389
|
-
var
|
|
390
|
-
function
|
|
391
|
-
|
|
392
|
-
|
|
621
|
+
var logger2 = core.logger.child({ module: "deploy", scope: "scanner" });
|
|
622
|
+
function record(value) {
|
|
623
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
624
|
+
}
|
|
625
|
+
function runtimeDependencies(appDir) {
|
|
626
|
+
let root = appDir;
|
|
627
|
+
while (!existsSync(join(root, "pnpm-lock.yaml"))) {
|
|
628
|
+
const parent = dirname(root);
|
|
629
|
+
if (parent === root)
|
|
630
|
+
throw new Error("Missing pnpm lockfile; run pnpm install");
|
|
631
|
+
root = parent;
|
|
393
632
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
633
|
+
const importerKey = relative(root, appDir).replaceAll("\\", "/") || ".";
|
|
634
|
+
const documents = parseAllDocuments(readFileSync(join(root, "pnpm-lock.yaml"), "utf8"));
|
|
635
|
+
if (documents.some((document) => document.errors.length))
|
|
636
|
+
throw new Error("Invalid pnpm lockfile");
|
|
637
|
+
const lock = documents.map((document) => record(document.toJS())).reverse().find((document) => importerKey in record(document.importers));
|
|
638
|
+
if (!lock)
|
|
639
|
+
throw new Error("Application is missing from pnpm lockfile; run pnpm install");
|
|
640
|
+
const importers = record(lock.importers);
|
|
641
|
+
const packages = record(lock.packages);
|
|
642
|
+
const snapshots = record(lock.snapshots);
|
|
643
|
+
const names = /* @__PURE__ */ new Set();
|
|
644
|
+
const visited = /* @__PURE__ */ new Set();
|
|
645
|
+
const visit = (id, node, importer) => {
|
|
646
|
+
if (visited.has(id))
|
|
647
|
+
return;
|
|
648
|
+
visited.add(id);
|
|
649
|
+
const metadata = importer === void 0 ? record(packages[id.split("(")[0]]) : {};
|
|
650
|
+
const peers = record(metadata.peerDependencies);
|
|
651
|
+
for (const [name, reference] of Object.entries({ ...record(node.dependencies), ...record(node.optionalDependencies) })) {
|
|
652
|
+
if (name in peers)
|
|
653
|
+
continue;
|
|
654
|
+
const version = typeof reference === "string" ? reference : record(reference).version;
|
|
655
|
+
if (typeof version !== "string")
|
|
656
|
+
throw new Error("Invalid pnpm dependency reference");
|
|
657
|
+
if (version.startsWith("link:")) {
|
|
658
|
+
if (importer === void 0)
|
|
659
|
+
throw new Error("Invalid workspace dependency");
|
|
660
|
+
const target = relative(root, resolve(root, importer, version.slice(5))).replaceAll("\\", "/") || ".";
|
|
661
|
+
const linked = record(importers[target]);
|
|
662
|
+
if (!Object.keys(linked).length)
|
|
663
|
+
throw new Error("Missing linked workspace importer");
|
|
664
|
+
names.add(name);
|
|
665
|
+
visit(`workspace:${target}`, linked, target);
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
const key = version in snapshots || version in packages ? version : `${name}@${version}`;
|
|
669
|
+
names.add(key.match(/^(@[^/]+\/[^@]+|[^@]+)@/)?.[1] ?? name);
|
|
670
|
+
const snapshot = record(snapshots[key] ?? packages[key]);
|
|
671
|
+
if (!Object.keys(snapshot).length && !(key in snapshots) && !(key in packages))
|
|
672
|
+
throw new Error("Missing pnpm dependency snapshot");
|
|
673
|
+
visit(key, snapshot);
|
|
399
674
|
}
|
|
400
|
-
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
}
|
|
675
|
+
};
|
|
676
|
+
visit(`workspace:${importerKey}`, record(importers[importerKey]), importerKey);
|
|
677
|
+
return { runtime: names, build: /* @__PURE__ */ new Set([...names, ...Object.keys(record(record(importers[importerKey]).devDependencies))]), root };
|
|
404
678
|
}
|
|
405
|
-
function
|
|
406
|
-
if (!existsSync(filePath)) {
|
|
407
|
-
return null;
|
|
408
|
-
}
|
|
679
|
+
async function scanApp(inputDir, options) {
|
|
409
680
|
try {
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
const dbFile = files.find((f) => f.startsWith("_db."));
|
|
429
|
-
if (dbFile) {
|
|
430
|
-
const dbConfig = readYaml(join(configDir, dbFile));
|
|
431
|
-
if (dbConfig !== null) {
|
|
432
|
-
services.push("db");
|
|
433
|
-
backends.db = dbConfig.type ?? "postgresql";
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
const cacheFile = files.find((f) => f.startsWith("_cache."));
|
|
437
|
-
if (cacheFile) {
|
|
438
|
-
const cacheConfig = readYaml(join(configDir, cacheFile));
|
|
439
|
-
if (cacheConfig !== null && cacheConfig.type === "redis") {
|
|
440
|
-
services.push("cache");
|
|
441
|
-
backends.cache = "redis";
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
const storageFile = files.find((f) => f.startsWith("_storage."));
|
|
445
|
-
if (storageFile) {
|
|
446
|
-
const storageConfig = readYaml(join(configDir, storageFile));
|
|
447
|
-
if (storageConfig !== null && detectStorageIsS3(storageConfig)) {
|
|
448
|
-
services.push("storage");
|
|
449
|
-
backends.storage = "s3";
|
|
681
|
+
const appDir = resolve(inputDir);
|
|
682
|
+
const graph = runtimeDependencies(appDir);
|
|
683
|
+
const envDir = options?.envDir ? resolve(options.envDir) : existsSync(join(appDir, ".env")) ? appDir : graph.root;
|
|
684
|
+
const loaded = loadDeploymentEnvironment({ environment: options?.environment ?? "prod", envDir, requireFile: options?.environment !== void 0 });
|
|
685
|
+
if (!loaded.success)
|
|
686
|
+
return loaded;
|
|
687
|
+
const env = loaded.data.values;
|
|
688
|
+
const deps = graph.runtime;
|
|
689
|
+
const configs = {};
|
|
690
|
+
for (const [name, owner] of Object.entries({ db: "reldb", cache: "cache", storage: "storage", vecdb: "vecdb", iam: "iam", reach: "reach" })) {
|
|
691
|
+
configs[name] = {};
|
|
692
|
+
if (!deps.has(`@h-ai/${owner}`))
|
|
693
|
+
continue;
|
|
694
|
+
const configFile = ["yml", "yaml"].map((ext) => join(appDir, "config", `_${name}.${ext}`)).find(existsSync);
|
|
695
|
+
const config = configFile ? loadDeploymentConfig(name, configFile, env) : void 0;
|
|
696
|
+
if (config && !config.success)
|
|
697
|
+
return config;
|
|
698
|
+
configs[name] = config?.data ?? {};
|
|
450
699
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
700
|
+
const middlewares = [];
|
|
701
|
+
const add = (service, backend, configName = service, configPath = [], config = configs[configName] ?? {}, source = `_${configName}.yml`) => {
|
|
702
|
+
middlewares.push({ service, backend, source, configName, configPath, envPrefix: ["HAI", configName, ...configPath].map((part) => part.toUpperCase()).join("_"), config });
|
|
703
|
+
};
|
|
704
|
+
const iam = deps.has("@h-ai/iam");
|
|
705
|
+
if (deps.has("@h-ai/reldb"))
|
|
706
|
+
add("db", String(configs.db.type ?? "sqlite"), "db", [], configs.db, "@h-ai/reldb / _db.yml");
|
|
707
|
+
if (deps.has("@h-ai/cache"))
|
|
708
|
+
add("cache", String(configs.cache.type ?? "memory"), "cache", [], configs.cache, "@h-ai/cache / _cache.yml");
|
|
709
|
+
if (deps.has("@h-ai/storage"))
|
|
710
|
+
add("storage", String(configs.storage.type ?? "local"));
|
|
711
|
+
if (deps.has("@h-ai/vecdb"))
|
|
712
|
+
add("vecdb", String(configs.vecdb.type ?? "lancedb"));
|
|
713
|
+
if (iam) {
|
|
714
|
+
const ldap = record(configs.iam.ldap);
|
|
715
|
+
add("ldap", record(configs.iam.login).ldap === true ? "ldap" : "disabled", "iam", ["ldap"], ldap, "@h-ai/iam (LDAP)");
|
|
458
716
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
717
|
+
const providers = configs.reach.providers;
|
|
718
|
+
if (deps.has("@h-ai/reach") && Array.isArray(providers)) {
|
|
719
|
+
for (const [index, item] of providers.entries()) {
|
|
720
|
+
const provider = record(item);
|
|
721
|
+
const type = String(provider.type ?? "");
|
|
722
|
+
const service = type === "aliyun-sms" ? "sms" : ["smtp", "api", "console"].includes(type) ? "email" : void 0;
|
|
723
|
+
if (service)
|
|
724
|
+
add(service, type, "reach", ["providers", String(index)], provider, "@h-ai/reach");
|
|
725
|
+
}
|
|
465
726
|
}
|
|
727
|
+
if (deps.has("@h-ai/reach") && !middlewares.some((item) => item.configName === "reach"))
|
|
728
|
+
add("email", "console", "reach", ["providers", "0"], { name: "email", type: "console" }, "@h-ai/reach");
|
|
729
|
+
const isSvelteKit = graph.build.has("@sveltejs/kit");
|
|
730
|
+
const scan = {
|
|
731
|
+
appDir,
|
|
732
|
+
envDir,
|
|
733
|
+
middlewares,
|
|
734
|
+
haiModules: [...deps].filter((name) => name.startsWith("@h-ai/")).sort(),
|
|
735
|
+
appName: basename(appDir),
|
|
736
|
+
isSvelteKit,
|
|
737
|
+
adapterInstalled: graph.build.has("@sveltejs/adapter-vercel"),
|
|
738
|
+
nodeAdapterInstalled: graph.build.has("@sveltejs/adapter-node"),
|
|
739
|
+
requiredServices: [...new Set(middlewares.map((item) => item.service))],
|
|
740
|
+
serviceBackends: Object.fromEntries(middlewares.map((item) => [item.service, item.backend]))
|
|
741
|
+
};
|
|
742
|
+
logger2.info("Application scanned", { appName: scan.appName, haiModules: scan.haiModules, requiredServices: scan.requiredServices, serviceBackends: scan.serviceBackends });
|
|
743
|
+
return ok(scan);
|
|
744
|
+
} catch {
|
|
745
|
+
return err(HaiDeployError.SCAN_FAILED, deployM("deploy_scanFailed", { params: { error: "Invalid or ambiguous application/configuration/lockfile; specify the application directory and run pnpm install" } }));
|
|
466
746
|
}
|
|
467
|
-
return { services, backends };
|
|
468
|
-
}
|
|
469
|
-
function detectStorageIsS3(config) {
|
|
470
|
-
if (config.type === "s3") {
|
|
471
|
-
return true;
|
|
472
|
-
}
|
|
473
|
-
const providers = config.providers;
|
|
474
|
-
if (providers === void 0) {
|
|
475
|
-
return false;
|
|
476
|
-
}
|
|
477
|
-
return Object.values(providers).some((p) => p?.type === "s3");
|
|
478
747
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
if (!
|
|
482
|
-
return;
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
const providerType = provider.type;
|
|
488
|
-
if (!hasEmail && (providerType === "smtp" || providerType === "api")) {
|
|
489
|
-
hasEmail = true;
|
|
490
|
-
services.push("email");
|
|
491
|
-
}
|
|
492
|
-
if (!hasSms && providerType === "aliyun-sms") {
|
|
493
|
-
hasSms = true;
|
|
494
|
-
services.push("sms");
|
|
495
|
-
}
|
|
748
|
+
var logger3 = core.logger.child({ module: "deploy", scope: "command" });
|
|
749
|
+
function maskSecrets(text, secrets) {
|
|
750
|
+
if (!secrets || secrets.length === 0)
|
|
751
|
+
return text;
|
|
752
|
+
let masked = text;
|
|
753
|
+
for (const secret of secrets) {
|
|
754
|
+
if (secret && secret.length > 0)
|
|
755
|
+
masked = masked.split(secret).join("***");
|
|
496
756
|
}
|
|
757
|
+
return masked;
|
|
497
758
|
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
const scripts = pkg.scripts;
|
|
517
|
-
const buildCommand = scripts?.build ?? "pnpm build";
|
|
518
|
-
const scanResult = {
|
|
519
|
-
appName,
|
|
520
|
-
isSvelteKit,
|
|
521
|
-
adapterInstalled,
|
|
522
|
-
nodeAdapterInstalled,
|
|
523
|
-
requiredServices,
|
|
524
|
-
serviceBackends: configDetection.backends,
|
|
525
|
-
buildCommand
|
|
759
|
+
function runCommand(command, args, options = {}) {
|
|
760
|
+
return new Promise((resolve6) => {
|
|
761
|
+
const child = spawn(command, args, {
|
|
762
|
+
cwd: options.cwd,
|
|
763
|
+
env: options.env ? { ...process2.env, ...options.env } : process2.env,
|
|
764
|
+
shell: false
|
|
765
|
+
});
|
|
766
|
+
let stdout = "";
|
|
767
|
+
let stderr = "";
|
|
768
|
+
let settled = false;
|
|
769
|
+
let timer;
|
|
770
|
+
const finish = (result) => {
|
|
771
|
+
if (settled)
|
|
772
|
+
return;
|
|
773
|
+
settled = true;
|
|
774
|
+
if (timer)
|
|
775
|
+
clearTimeout(timer);
|
|
776
|
+
resolve6(result);
|
|
526
777
|
};
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
778
|
+
if (options.timeoutMs && options.timeoutMs > 0) {
|
|
779
|
+
timer = setTimeout(() => {
|
|
780
|
+
child.kill("SIGKILL");
|
|
781
|
+
logger3.error("Command timed out", { command, timeoutMs: options.timeoutMs });
|
|
782
|
+
finish(err(
|
|
783
|
+
HaiDeployError.REMOTE_COMMAND_FAILED,
|
|
784
|
+
deployM("deploy_commandTimeout", { params: { command } })
|
|
785
|
+
));
|
|
786
|
+
}, options.timeoutMs);
|
|
787
|
+
}
|
|
788
|
+
child.stdout.on("data", (chunk) => {
|
|
789
|
+
stdout += chunk.toString("utf-8");
|
|
534
790
|
});
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
791
|
+
child.stderr.on("data", (chunk) => {
|
|
792
|
+
stderr += chunk.toString("utf-8");
|
|
793
|
+
});
|
|
794
|
+
child.on("error", (error) => {
|
|
795
|
+
logger3.error("Command failed to start", { command, error: maskSecrets(String(error), options.secrets) });
|
|
796
|
+
finish(err(
|
|
797
|
+
HaiDeployError.REMOTE_COMMAND_FAILED,
|
|
798
|
+
deployM("deploy_commandFailed", {
|
|
799
|
+
params: { command, error: maskSecrets(error.message, options.secrets) }
|
|
800
|
+
}),
|
|
801
|
+
error
|
|
802
|
+
));
|
|
803
|
+
});
|
|
804
|
+
child.on("close", (code) => {
|
|
805
|
+
const exitCode = code ?? -1;
|
|
806
|
+
if (exitCode === 0) {
|
|
807
|
+
finish(ok({ code: exitCode, stdout, stderr }));
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
logger3.error("Command exited with non-zero code", {
|
|
811
|
+
command,
|
|
812
|
+
code: exitCode,
|
|
813
|
+
stderr: maskSecrets(stderr, options.secrets).slice(-500),
|
|
814
|
+
stdout: maskSecrets(stdout, options.secrets).slice(-3e3)
|
|
815
|
+
});
|
|
816
|
+
finish(err(
|
|
817
|
+
HaiDeployError.REMOTE_COMMAND_FAILED,
|
|
818
|
+
deployM("deploy_commandFailed", {
|
|
819
|
+
params: { command, error: maskSecrets(`${stdout.slice(-2500)}
|
|
820
|
+
${stderr || `exit ${exitCode}`}`, options.secrets).slice(-3e3) }
|
|
821
|
+
})
|
|
822
|
+
));
|
|
823
|
+
});
|
|
824
|
+
if (options.input !== void 0) {
|
|
825
|
+
child.stdin.write(options.input);
|
|
826
|
+
child.stdin.end();
|
|
827
|
+
} else {
|
|
828
|
+
child.stdin.end();
|
|
558
829
|
}
|
|
559
|
-
}
|
|
560
|
-
return services;
|
|
830
|
+
});
|
|
561
831
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
832
|
+
|
|
833
|
+
// src/container/deploy-bundle.ts
|
|
834
|
+
function shellArgument(value) {
|
|
835
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
836
|
+
}
|
|
837
|
+
async function createDeploymentBundle(options) {
|
|
838
|
+
const { directory, artifactDir, artifact, projectName, environment, runtime, coreEnv } = options;
|
|
839
|
+
const stem = basename(options.output).replace(/\.tar$/i, "");
|
|
840
|
+
const versionSuffix = `-${artifact.appVersion}`;
|
|
841
|
+
const output = resolve(dirname(options.output), `${stem.endsWith(versionSuffix) ? stem : `${stem}${versionSuffix}`}.tar`);
|
|
842
|
+
const composeName = `docker-compose-${artifact.appVersion}.yml`;
|
|
843
|
+
mkdirSync(dirname(output), { recursive: true, mode: 448 });
|
|
844
|
+
const staging = mkdtempSync(join(directory, ".bundle-"));
|
|
845
|
+
chmodSync(staging, 448);
|
|
846
|
+
const temporaryTar = `${output}.partial`;
|
|
847
|
+
const startName = `start-${environment}.sh`;
|
|
848
|
+
try {
|
|
849
|
+
mkdirSync(join(staging, "images"));
|
|
850
|
+
for (const image of artifact.images)
|
|
851
|
+
copyFileSync(join(artifactDir, image.file), join(staging, "images", image.file));
|
|
852
|
+
for (const file of [composeName, ".env.runtime", ...artifact.images.some((image) => image.name === "web") ? ["web.conf"] : []])
|
|
853
|
+
copyFileSync(join(directory, file), join(staging, file));
|
|
854
|
+
const compose = parse(readFileSync(join(directory, composeName), "utf8"));
|
|
855
|
+
const images = [...new Set(Object.values(compose.services).flatMap((service) => service.image ? [service.image] : []))];
|
|
856
|
+
const start = `#!/bin/sh
|
|
857
|
+
set -eu
|
|
858
|
+
cd "$(dirname "$0")"
|
|
859
|
+
umask 077
|
|
860
|
+
export HAI_DEPLOY_ENVIRONMENT=${shellArgument(environment)}
|
|
861
|
+
export HAI_CORE_ENV=${shellArgument(coreEnv)}
|
|
862
|
+
runtime=${shellArgument(runtime)}
|
|
863
|
+
if [ -n "\${HAI_DEPLOY_PROVIDER_REMOTE_RUNTIME:-}" ]; then runtime="$HAI_DEPLOY_PROVIDER_REMOTE_RUNTIME"; fi
|
|
864
|
+
if [ "$runtime" = auto ]; then
|
|
865
|
+
if command -v podman >/dev/null 2>&1 && podman version >/dev/null 2>&1; then runtime=podman; else runtime=docker; fi
|
|
866
|
+
fi
|
|
867
|
+
case "$runtime" in docker|podman) ;; *) echo 'HAI_DEPLOY_PROVIDER_REMOTE_RUNTIME must be docker or podman' >&2; exit 1;; esac
|
|
868
|
+
"$runtime" version >/dev/null
|
|
869
|
+
"$runtime" compose version >/dev/null
|
|
870
|
+
sha256sum -c SHA256SUMS
|
|
871
|
+
chmod 600 .env.runtime
|
|
872
|
+
${artifact.images.map((image) => `"$runtime" load -i ${shellArgument(`images/${image.file}`)}`).join("\n")}
|
|
873
|
+
# \u79BB\u7EBF\u670D\u52A1\u5668\u5E94\u5148\u5BFC\u5165\u5168\u90E8\u4E2D\u95F4\u4EF6\u955C\u50CF\uFF1B\u7981\u6B62\u9759\u9ED8\u5C1D\u8BD5\u8054\u7F51\u3002
|
|
874
|
+
for image in ${images.map(shellArgument).join(" ")}; do
|
|
875
|
+
"$runtime" image inspect "$image" >/dev/null 2>&1 || { echo "Missing image: $image. Load the matching image archive before retrying." >&2; exit 1; }
|
|
876
|
+
done
|
|
877
|
+
exec "$runtime" compose -f ${shellArgument(composeName)} --env-file .env.runtime -p ${shellArgument(`hai-${projectName}`)} up -d
|
|
878
|
+
`;
|
|
879
|
+
writeFileSync(join(directory, startName), start, { mode: 448 });
|
|
880
|
+
writeFileSync(join(staging, startName), start, { mode: 448 });
|
|
881
|
+
const instructions = deployM("deploy_bundleInstructions", { params: { file: basename(output), start: startName, images: images.join("\n"), compose: composeName } });
|
|
882
|
+
const metadata = deployM("deploy_bundleMetadata", { params: { version: artifact.appVersion, buildId: artifact.buildId, compose: composeName } });
|
|
883
|
+
writeFileSync(join(staging, "README.txt"), `${metadata}
|
|
884
|
+
${instructions}
|
|
885
|
+
`);
|
|
886
|
+
copyFileSync(join(artifactDir, "manifest.json"), join(staging, "manifest.json"));
|
|
887
|
+
const checksums = [];
|
|
888
|
+
for (const file of [composeName, "manifest.json", ".env.runtime", startName, ...artifact.images.some((image) => image.name === "web") ? ["web.conf"] : [], ...artifact.images.map((image) => `images/${image.file}`)])
|
|
889
|
+
checksums.push(`${await artifactChecksum(join(staging, file))} ${file}`);
|
|
890
|
+
writeFileSync(join(staging, "SHA256SUMS"), `${checksums.join("\n")}
|
|
891
|
+
`);
|
|
892
|
+
const tar = await runCommand("tar", ["-cf", temporaryTar, "-C", staging, "."]);
|
|
893
|
+
if (!tar.success)
|
|
894
|
+
throw new Error(tar.error.message);
|
|
895
|
+
chmodSync(temporaryTar, 384);
|
|
896
|
+
const { renameSync } = await import('fs');
|
|
897
|
+
renameSync(temporaryTar, output);
|
|
898
|
+
return { bundlePath: output, instructions, startName };
|
|
899
|
+
} finally {
|
|
900
|
+
rmSync(staging, { recursive: true, force: true });
|
|
901
|
+
rmSync(temporaryTar, { force: true });
|
|
567
902
|
}
|
|
568
|
-
return name;
|
|
569
903
|
}
|
|
570
904
|
var IMAGES = {
|
|
571
905
|
postgres: "postgres:16-alpine",
|
|
572
906
|
mysql: "mysql:8.4",
|
|
573
907
|
redis: "redis:7-alpine",
|
|
574
|
-
|
|
575
|
-
|
|
908
|
+
rustfs: "ghcr.io/rustfs/rustfs:1.0.1",
|
|
909
|
+
minio: "ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z",
|
|
576
910
|
qdrant: "qdrant/qdrant:v1.12.4",
|
|
577
911
|
pgvector: "pgvector/pgvector:pg17",
|
|
578
|
-
chroma: "chromadb/chroma:
|
|
912
|
+
chroma: "chromadb/chroma:1.5.5",
|
|
913
|
+
ldap: "docker.io/osixia/openldap:1.5.0",
|
|
914
|
+
smtp: "axllent/mailpit:v1.27.4"
|
|
579
915
|
};
|
|
580
916
|
function generatePassword() {
|
|
581
917
|
return randomBytes(24).toString("base64url");
|
|
@@ -590,7 +926,8 @@ function resolveSidecarSecrets(appDir, projectName) {
|
|
|
590
926
|
}
|
|
591
927
|
const secrets = {
|
|
592
928
|
dbPassword: generatePassword(),
|
|
593
|
-
storagePassword: generatePassword()
|
|
929
|
+
storagePassword: generatePassword(),
|
|
930
|
+
ldapPassword: generatePassword()
|
|
594
931
|
};
|
|
595
932
|
mkdirSync(dirname(secretFile), { recursive: true });
|
|
596
933
|
writeFileSync(secretFile, JSON.stringify(secrets), { mode: 384 });
|
|
@@ -651,38 +988,57 @@ function planRedisCache(plan, projectName, net) {
|
|
|
651
988
|
};
|
|
652
989
|
plan.env.HAI_CACHE = JSON.stringify({ type: "redis", url: "redis://cache:6379" });
|
|
653
990
|
}
|
|
654
|
-
function
|
|
991
|
+
function planS3Storage(plan, projectName, password, net, engine) {
|
|
655
992
|
plan.services.push("storage");
|
|
656
993
|
plan.dependsOn.push("storage");
|
|
657
994
|
plan.composeServices.storage = {
|
|
658
|
-
image: IMAGES
|
|
995
|
+
image: IMAGES[engine],
|
|
659
996
|
container_name: `hai-${projectName}-storage`,
|
|
660
997
|
restart: "unless-stopped",
|
|
661
|
-
command: ["server", "/data"],
|
|
998
|
+
...engine === "minio" ? { command: ["server", "/data"] } : {},
|
|
662
999
|
env_file: [".env.runtime"],
|
|
663
1000
|
volumes: [`${projectName}-storage-data:/data`],
|
|
664
1001
|
networks: net
|
|
665
1002
|
};
|
|
666
1003
|
plan.composeServices["storage-init"] = {
|
|
667
|
-
image: IMAGES.mc,
|
|
668
1004
|
depends_on: ["storage"],
|
|
669
1005
|
env_file: [".env.runtime"],
|
|
670
1006
|
entrypoint: [
|
|
671
|
-
"
|
|
672
|
-
"-
|
|
673
|
-
|
|
1007
|
+
"node",
|
|
1008
|
+
"--input-type=module",
|
|
1009
|
+
"-e",
|
|
1010
|
+
// 复用应用 @h-ai/storage 已依赖的 SDK;不额外拉取失效的 mc 镜像,不把密钥拼进命令。
|
|
1011
|
+
`import { S3Client, HeadBucketCommand, CreateBucketCommand } from '@aws-sdk/client-s3';
|
|
1012
|
+
const config = JSON.parse(process.env.HAI_STORAGE);
|
|
1013
|
+
const client = new S3Client({ endpoint: config.endpoint, region: config.region, forcePathStyle: config.forcePathStyle, credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey } });
|
|
1014
|
+
let ready = false;
|
|
1015
|
+
for (let attempt = 0; attempt < 120; attempt++) {
|
|
1016
|
+
try {
|
|
1017
|
+
try { await client.send(new HeadBucketCommand({ Bucket: config.bucket })); }
|
|
1018
|
+
catch (error) {
|
|
1019
|
+
if (error.$$metadata?.httpStatusCode !== 404) throw error;
|
|
1020
|
+
await client.send(new CreateBucketCommand({ Bucket: config.bucket }));
|
|
1021
|
+
}
|
|
1022
|
+
ready = true; break;
|
|
1023
|
+
} catch { await new Promise(resolve => setTimeout(resolve, 1000)); }
|
|
1024
|
+
}
|
|
1025
|
+
client.destroy();
|
|
1026
|
+
if (!ready) throw new Error('S3 bucket initialization failed');`
|
|
674
1027
|
],
|
|
675
1028
|
networks: net
|
|
676
1029
|
};
|
|
677
1030
|
plan.volumes[`${projectName}-storage-data`] = null;
|
|
678
|
-
|
|
679
|
-
|
|
1031
|
+
if (engine === "rustfs") {
|
|
1032
|
+
Object.assign(plan.env, { RUSTFS_ACCESS_KEY: "hai-storage", RUSTFS_SECRET_KEY: password, RUSTFS_ADDRESS: "0.0.0.0:9000", RUSTFS_VOLUMES: "/data", RUSTFS_CONSOLE_ENABLE: "false" });
|
|
1033
|
+
} else {
|
|
1034
|
+
Object.assign(plan.env, { MINIO_ROOT_USER: "hai-storage", MINIO_ROOT_PASSWORD: password });
|
|
1035
|
+
}
|
|
680
1036
|
plan.env.HAI_STORAGE = JSON.stringify({
|
|
681
1037
|
type: "s3",
|
|
682
1038
|
endpoint: "http://storage:9000",
|
|
683
1039
|
bucket: "hai",
|
|
684
1040
|
region: "us-east-1",
|
|
685
|
-
accessKeyId: "hai",
|
|
1041
|
+
accessKeyId: "hai-storage",
|
|
686
1042
|
secretAccessKey: password,
|
|
687
1043
|
forcePathStyle: true
|
|
688
1044
|
});
|
|
@@ -732,7 +1088,7 @@ function planChromaVecdb(plan, projectName, net) {
|
|
|
732
1088
|
plan.volumes[`${projectName}-vecdb-data`] = null;
|
|
733
1089
|
plan.env.HAI_VECDB = JSON.stringify({ type: "chroma", url: "http://vecdb:8000" });
|
|
734
1090
|
}
|
|
735
|
-
function planSidecars(appDir, projectName, scan, provisionEnv) {
|
|
1091
|
+
function planSidecars(appDir, projectName, scan, provisionEnv, options, s3Engine = "rustfs") {
|
|
736
1092
|
const plan = {
|
|
737
1093
|
services: [],
|
|
738
1094
|
composeServices: {},
|
|
@@ -742,10 +1098,14 @@ function planSidecars(appDir, projectName, scan, provisionEnv) {
|
|
|
742
1098
|
dependsOn: [],
|
|
743
1099
|
secrets: []
|
|
744
1100
|
};
|
|
1101
|
+
if (!scan.requiredServices.some((service) => usesDefaultMiddleware(scan, service, options)))
|
|
1102
|
+
return plan;
|
|
745
1103
|
const secrets = resolveSidecarSecrets(appDir, projectName);
|
|
746
1104
|
const net = ["hai-app"];
|
|
747
1105
|
const backends = scan.serviceBackends;
|
|
748
1106
|
for (const service of scan.requiredServices) {
|
|
1107
|
+
if (!usesDefaultMiddleware(scan, service, options))
|
|
1108
|
+
continue;
|
|
749
1109
|
if (service === "db" && !provisionEnv.HAI_DB) {
|
|
750
1110
|
const backend = backends.db ?? "postgresql";
|
|
751
1111
|
if (backend === "mysql")
|
|
@@ -755,9 +1115,13 @@ function planSidecars(appDir, projectName, scan, provisionEnv) {
|
|
|
755
1115
|
else
|
|
756
1116
|
planPostgresDb(plan, projectName, secrets.dbPassword, net);
|
|
757
1117
|
} else if (service === "cache" && !provisionEnv.HAI_CACHE) {
|
|
758
|
-
|
|
1118
|
+
if ((backends.cache ?? "redis") === "redis")
|
|
1119
|
+
planRedisCache(plan, projectName, net);
|
|
759
1120
|
} else if (service === "storage" && !provisionEnv.HAI_STORAGE) {
|
|
760
|
-
|
|
1121
|
+
if ((backends.storage ?? "s3") === "s3")
|
|
1122
|
+
planS3Storage(plan, projectName, secrets.storagePassword, net, s3Engine);
|
|
1123
|
+
else
|
|
1124
|
+
addAppDataVolume(plan, projectName);
|
|
761
1125
|
} else if (service === "vecdb" && !provisionEnv.HAI_VECDB) {
|
|
762
1126
|
const backend = backends.vecdb ?? "qdrant";
|
|
763
1127
|
if (backend === "pgvector")
|
|
@@ -768,16 +1132,39 @@ function planSidecars(appDir, projectName, scan, provisionEnv) {
|
|
|
768
1132
|
addAppDataVolume(plan, projectName);
|
|
769
1133
|
else
|
|
770
1134
|
planQdrantVecdb(plan, projectName, net);
|
|
1135
|
+
} else if (service === "ldap" && backends.ldap === "ldap" && !provisionEnv.HAI_IAM_LDAP) {
|
|
1136
|
+
const password = secrets.ldapPassword ?? secrets.dbPassword;
|
|
1137
|
+
plan.services.push("ldap");
|
|
1138
|
+
plan.dependsOn.push("ldap");
|
|
1139
|
+
plan.composeServices.ldap = { image: IMAGES.ldap, restart: "unless-stopped", env_file: [".env.runtime"], networks: net, volumes: [`${projectName}-ldap-data:/var/lib/ldap`, `${projectName}-ldap-config:/etc/ldap/slapd.d`] };
|
|
1140
|
+
plan.volumes[`${projectName}-ldap-data`] = null;
|
|
1141
|
+
plan.volumes[`${projectName}-ldap-config`] = null;
|
|
1142
|
+
Object.assign(plan.env, { LDAP_DOMAIN: "example.org", LDAP_ORGANISATION: "HAI", LDAP_ADMIN_PASSWORD: password, LDAP_TLS: "false", HAI_IAM_LOGIN_LDAP: "true", HAI_IAM_LDAP: JSON.stringify({ url: "ldap://ldap:389", bindDn: "cn=admin,dc=example,dc=org", bindPassword: password, searchBase: "dc=example,dc=org" }) });
|
|
1143
|
+
plan.secrets.push(password);
|
|
1144
|
+
} else if (service === "email" && (scan.middlewares ? scan.middlewares.some((item) => item.service === "email" && item.backend === "smtp" && (options?.serviceModes?.[item.envPrefix] ?? options?.serviceModes?.email ?? "default") === "default") : backends.email === "smtp") && !provisionEnv.HAI_REACH_PROVIDERS) {
|
|
1145
|
+
plan.services.push("email");
|
|
1146
|
+
plan.dependsOn.push("email");
|
|
1147
|
+
plan.composeServices.email = { image: IMAGES.smtp, restart: "unless-stopped", networks: net };
|
|
1148
|
+
const requirements = scan.middlewares?.filter((item) => item.service === "email" && item.backend === "smtp" && ["default", void 0].includes(options?.serviceModes?.[item.envPrefix] ?? options?.serviceModes?.email));
|
|
1149
|
+
if (requirements?.length) {
|
|
1150
|
+
for (const requirement of requirements)
|
|
1151
|
+
plan.env[requirement.envPrefix] = JSON.stringify({ name: requirement.config.name ?? "email", type: "smtp", host: "email", port: 1025, secure: false, from: "noreply@example.com" });
|
|
1152
|
+
} else if (!scan.middlewares) {
|
|
1153
|
+
plan.env.HAI_REACH_PROVIDERS_0 = JSON.stringify({ name: "email", type: "smtp", host: "email", port: 1025, secure: false, from: "noreply@example.com" });
|
|
1154
|
+
}
|
|
771
1155
|
}
|
|
772
1156
|
}
|
|
773
1157
|
return plan;
|
|
774
1158
|
}
|
|
775
|
-
function buildRuntimeEnv(appDir, projectName, scan, provisionEnv, containerPort) {
|
|
776
|
-
const sidecar = planSidecars(appDir, projectName, scan, provisionEnv);
|
|
1159
|
+
function buildRuntimeEnv(appDir, projectName, scan, provisionEnv, containerPort, options, appEnv = {}, s3Engine = "rustfs") {
|
|
1160
|
+
const sidecar = planSidecars(appDir, projectName, scan, provisionEnv, options, s3Engine);
|
|
777
1161
|
const env = {
|
|
778
1162
|
NODE_ENV: "production",
|
|
779
1163
|
HOST: "0.0.0.0",
|
|
780
1164
|
PORT: String(containerPort),
|
|
1165
|
+
HAI_SERV_SERVER_HOST: "0.0.0.0",
|
|
1166
|
+
HAI_SERV_SERVER_PORT: String(containerPort),
|
|
1167
|
+
...appEnv,
|
|
781
1168
|
...sidecar.env,
|
|
782
1169
|
...provisionEnv
|
|
783
1170
|
};
|
|
@@ -787,6 +1174,7 @@ function generateComposeFile(options) {
|
|
|
787
1174
|
const { projectName, imageRef, hostPort, containerPort, sidecar } = options;
|
|
788
1175
|
const app = {
|
|
789
1176
|
image: imageRef,
|
|
1177
|
+
labels: { "org.opencontainers.image.version": options.appVersion },
|
|
790
1178
|
container_name: `hai-${projectName}-app`,
|
|
791
1179
|
restart: "unless-stopped",
|
|
792
1180
|
// 镜像已由 load 注入本地,禁止 compose 再从 registry 拉取
|
|
@@ -799,188 +1187,110 @@ function generateComposeFile(options) {
|
|
|
799
1187
|
app.volumes = sidecar.appVolumes;
|
|
800
1188
|
if (sidecar.dependsOn.length > 0)
|
|
801
1189
|
app.depends_on = sidecar.dependsOn;
|
|
1190
|
+
const services = { app, ...sidecar.composeServices };
|
|
1191
|
+
if (options.web) {
|
|
1192
|
+
delete app.ports;
|
|
1193
|
+
services.web = {
|
|
1194
|
+
image: options.web.imageRef,
|
|
1195
|
+
labels: { "org.opencontainers.image.version": options.appVersion },
|
|
1196
|
+
container_name: `hai-${projectName}-web`,
|
|
1197
|
+
restart: "unless-stopped",
|
|
1198
|
+
pull_policy: "never",
|
|
1199
|
+
ports: [`${hostPort}:${options.web.containerPort}`],
|
|
1200
|
+
environment: { PUBLIC_API_BASE: "" },
|
|
1201
|
+
healthcheck: { test: ["CMD", "wget", "-Y", "off", "-q", "-O", "/dev/null", `http://127.0.0.1:${options.web.containerPort}/_hai/web-health`], interval: "30s", timeout: "5s", retries: 3 },
|
|
1202
|
+
volumes: ["./web.conf:/etc/nginx/conf.d/default.conf:ro"],
|
|
1203
|
+
depends_on: ["app"],
|
|
1204
|
+
networks: ["hai-app"]
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
if (services["storage-init"])
|
|
1208
|
+
services["storage-init"] = { ...services["storage-init"], image: imageRef };
|
|
802
1209
|
const compose = {
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
},
|
|
807
|
-
networks: {
|
|
1210
|
+
"x-hai-version": options.appVersion,
|
|
1211
|
+
services,
|
|
1212
|
+
"networks": {
|
|
808
1213
|
"hai-app": { name: `hai-${projectName}` }
|
|
809
1214
|
}
|
|
810
1215
|
};
|
|
811
1216
|
if (Object.keys(sidecar.volumes).length > 0)
|
|
812
1217
|
compose.volumes = sidecar.volumes;
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
**/.turbo
|
|
824
|
-
**/.svelte-kit
|
|
825
|
-
**/coverage
|
|
826
|
-
**/test-results
|
|
827
|
-
**/playwright-report
|
|
828
|
-
**/.hai/deploy
|
|
829
|
-
.git
|
|
830
|
-
`;
|
|
831
|
-
}
|
|
832
|
-
function generateFallbackDockerfile(packageName, containerPort) {
|
|
833
|
-
return `# syntax=docker/dockerfile:1.7
|
|
834
|
-
FROM node:24-bookworm-slim AS build
|
|
835
|
-
ENV PNPM_HOME=/pnpm
|
|
836
|
-
ENV PATH=$PNPM_HOME:$PATH
|
|
837
|
-
ENV CI=true
|
|
838
|
-
WORKDIR /workspace
|
|
839
|
-
RUN corepack enable && corepack prepare pnpm@12.11.2 --activate
|
|
840
|
-
COPY . .
|
|
841
|
-
RUN pnpm install --frozen-lockfile --ignore-scripts
|
|
842
|
-
RUN pnpm --filter ${packageName} build
|
|
843
|
-
RUN pnpm pm deploy --filter ${packageName} --prod /prod/app
|
|
844
|
-
|
|
845
|
-
FROM node:24-bookworm-slim AS runtime
|
|
846
|
-
ENV NODE_ENV=production
|
|
847
|
-
ENV HOST=0.0.0.0
|
|
848
|
-
ENV PORT=${containerPort}
|
|
849
|
-
WORKDIR /app
|
|
850
|
-
COPY --from=build --chown=node:node /prod/app ./
|
|
851
|
-
USER node
|
|
852
|
-
EXPOSE ${containerPort}
|
|
853
|
-
CMD ["node", "build"]
|
|
854
|
-
`;
|
|
855
|
-
}
|
|
856
|
-
var logger4 = core.logger.child({ module: "deploy", scope: "command" });
|
|
857
|
-
function maskSecrets(text, secrets) {
|
|
858
|
-
if (!secrets || secrets.length === 0)
|
|
859
|
-
return text;
|
|
860
|
-
let masked = text;
|
|
861
|
-
for (const secret of secrets) {
|
|
862
|
-
if (secret && secret.length > 0)
|
|
863
|
-
masked = masked.split(secret).join("***");
|
|
864
|
-
}
|
|
865
|
-
return masked;
|
|
866
|
-
}
|
|
867
|
-
function runCommand(command, args, options = {}) {
|
|
868
|
-
return new Promise((resolve) => {
|
|
869
|
-
const child = spawn(command, args, {
|
|
870
|
-
cwd: options.cwd,
|
|
871
|
-
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
872
|
-
shell: false
|
|
873
|
-
});
|
|
874
|
-
let stdout = "";
|
|
875
|
-
let stderr = "";
|
|
876
|
-
let settled = false;
|
|
877
|
-
let timer;
|
|
878
|
-
const finish = (result) => {
|
|
879
|
-
if (settled)
|
|
880
|
-
return;
|
|
881
|
-
settled = true;
|
|
882
|
-
if (timer)
|
|
883
|
-
clearTimeout(timer);
|
|
884
|
-
resolve(result);
|
|
885
|
-
};
|
|
886
|
-
if (options.timeoutMs && options.timeoutMs > 0) {
|
|
887
|
-
timer = setTimeout(() => {
|
|
888
|
-
child.kill("SIGKILL");
|
|
889
|
-
logger4.error("Command timed out", { command, timeoutMs: options.timeoutMs });
|
|
890
|
-
finish(err(
|
|
891
|
-
HaiDeployError.REMOTE_COMMAND_FAILED,
|
|
892
|
-
deployM("deploy_commandTimeout", { params: { command } })
|
|
893
|
-
));
|
|
894
|
-
}, options.timeoutMs);
|
|
895
|
-
}
|
|
896
|
-
child.stdout.on("data", (chunk) => {
|
|
897
|
-
stdout += chunk.toString("utf-8");
|
|
898
|
-
});
|
|
899
|
-
child.stderr.on("data", (chunk) => {
|
|
900
|
-
stderr += chunk.toString("utf-8");
|
|
901
|
-
});
|
|
902
|
-
child.on("error", (error) => {
|
|
903
|
-
logger4.error("Command failed to start", { command, error: maskSecrets(String(error), options.secrets) });
|
|
904
|
-
finish(err(
|
|
905
|
-
HaiDeployError.REMOTE_COMMAND_FAILED,
|
|
906
|
-
deployM("deploy_commandFailed", {
|
|
907
|
-
params: { command, error: maskSecrets(error.message, options.secrets) }
|
|
908
|
-
}),
|
|
909
|
-
error
|
|
910
|
-
));
|
|
911
|
-
});
|
|
912
|
-
child.on("close", (code) => {
|
|
913
|
-
const exitCode = code ?? -1;
|
|
914
|
-
if (exitCode === 0) {
|
|
915
|
-
finish(ok({ code: exitCode, stdout, stderr }));
|
|
916
|
-
return;
|
|
1218
|
+
if (options.previousCompose && options.serviceModes) {
|
|
1219
|
+
const previous = parse(options.previousCompose);
|
|
1220
|
+
for (const [service, mode] of Object.entries(options.serviceModes)) {
|
|
1221
|
+
if (mode !== "keep")
|
|
1222
|
+
continue;
|
|
1223
|
+
for (const name of [service, `${service}-init`]) {
|
|
1224
|
+
if (previous.services?.[name]) {
|
|
1225
|
+
services[name] = previous.services[name];
|
|
1226
|
+
app.depends_on = [.../* @__PURE__ */ new Set([...app.depends_on ?? [], service])];
|
|
1227
|
+
}
|
|
917
1228
|
}
|
|
918
|
-
logger4.error("Command exited with non-zero code", {
|
|
919
|
-
command,
|
|
920
|
-
code: exitCode,
|
|
921
|
-
stderr: maskSecrets(stderr, options.secrets).slice(-500)
|
|
922
|
-
});
|
|
923
|
-
finish(err(
|
|
924
|
-
HaiDeployError.REMOTE_COMMAND_FAILED,
|
|
925
|
-
deployM("deploy_commandFailed", {
|
|
926
|
-
params: { command, error: maskSecrets(stderr || `exit ${exitCode}`, options.secrets).slice(-500) }
|
|
927
|
-
})
|
|
928
|
-
));
|
|
929
|
-
});
|
|
930
|
-
if (options.input !== void 0) {
|
|
931
|
-
child.stdin.write(options.input);
|
|
932
|
-
child.stdin.end();
|
|
933
|
-
} else {
|
|
934
|
-
child.stdin.end();
|
|
935
|
-
}
|
|
936
|
-
});
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
// src/container/deploy-container-runtime.ts
|
|
940
|
-
var logger5 = core.logger.child({ module: "deploy", scope: "container-runtime" });
|
|
941
|
-
async function detectLocalRuntime(preferred = "auto") {
|
|
942
|
-
const candidates = preferred === "auto" ? ["podman", "docker"] : [preferred];
|
|
943
|
-
for (const runtime of candidates) {
|
|
944
|
-
const probe = await runCommand(runtime, ["version"], { timeoutMs: 15e3 });
|
|
945
|
-
if (probe.success) {
|
|
946
|
-
logger5.info("Local container runtime detected", { runtime });
|
|
947
|
-
return ok(runtime);
|
|
948
1229
|
}
|
|
1230
|
+
const previousMounts = previous.services?.app?.volumes;
|
|
1231
|
+
if (Array.isArray(previousMounts) && Object.values(options.serviceModes).includes("keep"))
|
|
1232
|
+
app.volumes = [.../* @__PURE__ */ new Set([...app.volumes ?? [], ...previousMounts])];
|
|
1233
|
+
compose.volumes = { ...previous.volumes, ...sidecar.volumes };
|
|
949
1234
|
}
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
}
|
|
955
|
-
async function buildImage(runtime, options) {
|
|
956
|
-
const args = ["build", "-f", options.dockerfilePath, "-t", options.imageRef];
|
|
957
|
-
if (runtime === "podman")
|
|
958
|
-
args.push("--format", "docker", "--jobs", "1");
|
|
959
|
-
args.push(options.contextDir);
|
|
960
|
-
logger5.info("Building container image", { runtime, imageRef: options.imageRef });
|
|
961
|
-
const result = await runCommand(runtime, args, { cwd: options.contextDir });
|
|
962
|
-
if (!result.success) {
|
|
963
|
-
return err(
|
|
964
|
-
HaiDeployError.CONTAINER_BUILD_FAILED,
|
|
965
|
-
deployM("deploy_containerBuildFailed", { params: { error: result.error.message } }),
|
|
966
|
-
result.error
|
|
967
|
-
);
|
|
1235
|
+
if (services["storage-init"]) {
|
|
1236
|
+
app.depends_on = {
|
|
1237
|
+
...Object.fromEntries((app.depends_on ?? []).map((name) => [name, { condition: "service_started" }])),
|
|
1238
|
+
"storage-init": { condition: "service_completed_successfully" }
|
|
1239
|
+
};
|
|
968
1240
|
}
|
|
969
|
-
|
|
1241
|
+
for (const service of Object.values(services))
|
|
1242
|
+
service.pull_policy = "never";
|
|
1243
|
+
return stringify(compose);
|
|
970
1244
|
}
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1245
|
+
function generateWebProxyConfig(port, apiPrefix, webPort = 8080) {
|
|
1246
|
+
if (!/^\/[\w/-]+$/.test(apiPrefix) || ![port, webPort].every((value) => Number.isInteger(value) && value >= 1 && value <= 65535))
|
|
1247
|
+
throw new Error("Invalid web proxy target");
|
|
1248
|
+
return `server {
|
|
1249
|
+
listen ${webPort};
|
|
1250
|
+
server_name _;
|
|
1251
|
+
root /usr/share/nginx/html;
|
|
1252
|
+
location / {
|
|
1253
|
+
try_files $uri $uri/ /index.html;
|
|
1254
|
+
}
|
|
1255
|
+
location = /runtime-config.js {
|
|
1256
|
+
add_header Cache-Control "no-store";
|
|
1257
|
+
default_type application/javascript;
|
|
1258
|
+
return 200 'globalThis.__HAI_PUBLIC_CONFIG__={apiBase:globalThis.location.origin+"${apiPrefix}"};';
|
|
1259
|
+
}
|
|
1260
|
+
location = /_hai/web-health {
|
|
1261
|
+
access_log off;
|
|
1262
|
+
return 200 'ok';
|
|
1263
|
+
}
|
|
1264
|
+
location = /health {
|
|
1265
|
+
proxy_pass http://app:${port};
|
|
1266
|
+
proxy_set_header Host $http_host;
|
|
1267
|
+
}
|
|
1268
|
+
location ${apiPrefix.replace(/\/$/, "")}/ {
|
|
1269
|
+
proxy_pass http://app:${port};
|
|
1270
|
+
proxy_http_version 1.1;
|
|
1271
|
+
proxy_set_header Host $http_host;
|
|
1272
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
1273
|
+
proxy_buffering off;
|
|
980
1274
|
}
|
|
981
|
-
return ok(void 0);
|
|
982
1275
|
}
|
|
983
|
-
|
|
1276
|
+
`;
|
|
1277
|
+
}
|
|
1278
|
+
function serializeEnvFile(env) {
|
|
1279
|
+
return `${Object.entries(env).map(([key, value]) => {
|
|
1280
|
+
let literal = value;
|
|
1281
|
+
if (/[\s#$'"]/.test(value)) {
|
|
1282
|
+
if (value.startsWith("{") || value.startsWith("["))
|
|
1283
|
+
literal = `'${value.replaceAll("'", "\\u0027")}'`;
|
|
1284
|
+
else if (!value.includes("'"))
|
|
1285
|
+
literal = `'${value}'`;
|
|
1286
|
+
else
|
|
1287
|
+
literal = JSON.stringify(value).replaceAll("$", "$$");
|
|
1288
|
+
}
|
|
1289
|
+
return `${key}=${literal}`;
|
|
1290
|
+
}).join("\n")}
|
|
1291
|
+
`;
|
|
1292
|
+
}
|
|
1293
|
+
var logger4 = core.logger.child({ module: "deploy", scope: "ssh" });
|
|
984
1294
|
function buildConnectOptions(config) {
|
|
985
1295
|
const args = ["-o", "BatchMode=yes"];
|
|
986
1296
|
args.push("-o", `ConnectTimeout=${config.connectTimeoutSeconds}`);
|
|
@@ -1031,7 +1341,7 @@ function createSshExecutor(config) {
|
|
|
1031
1341
|
return ok(void 0);
|
|
1032
1342
|
};
|
|
1033
1343
|
const ping = async () => {
|
|
1034
|
-
|
|
1344
|
+
logger4.debug("SSH preflight", { host: config.host, port: config.port });
|
|
1035
1345
|
const result = await exec("true", { timeoutMs: 2e4 });
|
|
1036
1346
|
if (!result.success) {
|
|
1037
1347
|
return err(
|
|
@@ -1046,7 +1356,7 @@ function createSshExecutor(config) {
|
|
|
1046
1356
|
}
|
|
1047
1357
|
|
|
1048
1358
|
// src/providers/deploy-provider-docker-ssh.ts
|
|
1049
|
-
var
|
|
1359
|
+
var logger5 = core.logger.child({ module: "deploy", scope: "provider-docker-ssh" });
|
|
1050
1360
|
var HEALTH_POLL_INTERVAL = 3e3;
|
|
1051
1361
|
var HEALTH_MAX_ATTEMPTS = 40;
|
|
1052
1362
|
function makeDeploymentId() {
|
|
@@ -1055,38 +1365,15 @@ function makeDeploymentId() {
|
|
|
1055
1365
|
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
1056
1366
|
return `${stamp}-${randomBytes(3).toString("hex")}`;
|
|
1057
1367
|
}
|
|
1058
|
-
function parseLoadedImageRef(stdout, fallback) {
|
|
1059
|
-
const match = stdout.match(/Loaded image(?:\(s\))?:\s*(\S+)/);
|
|
1060
|
-
return match ? match[1] : fallback;
|
|
1061
|
-
}
|
|
1062
1368
|
function sleep(ms) {
|
|
1063
|
-
return new Promise((
|
|
1369
|
+
return new Promise((resolve6) => setTimeout(resolve6, ms));
|
|
1064
1370
|
}
|
|
1065
|
-
function
|
|
1066
|
-
|
|
1067
|
-
const pkg = JSON.parse(readFileSync(join(appDir, "package.json"), "utf-8"));
|
|
1068
|
-
return pkg.name ?? "app";
|
|
1069
|
-
} catch {
|
|
1070
|
-
return "app";
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
function resolveWorkspaceRoot(appDir) {
|
|
1074
|
-
if (existsSync(join(appDir, "pnpm-workspace.yaml")))
|
|
1075
|
-
return appDir;
|
|
1076
|
-
let current = appDir;
|
|
1077
|
-
let parent = dirname(current);
|
|
1078
|
-
while (parent !== current) {
|
|
1079
|
-
if (existsSync(join(parent, "pnpm-workspace.yaml")))
|
|
1080
|
-
return parent;
|
|
1081
|
-
current = parent;
|
|
1082
|
-
parent = dirname(parent);
|
|
1083
|
-
}
|
|
1084
|
-
return appDir;
|
|
1085
|
-
}
|
|
1086
|
-
function createDockerSshProvider(config, localRuntimePref = "auto") {
|
|
1087
|
-
const ssh = createSshExecutor(config.ssh);
|
|
1371
|
+
function createDockerSshProvider(config, container) {
|
|
1372
|
+
const ssh = config.ssh ? createSshExecutor(config.ssh) : void 0;
|
|
1088
1373
|
const baseDir = config.remote.baseDir;
|
|
1089
1374
|
async function detectRemoteRuntime() {
|
|
1375
|
+
if (!ssh)
|
|
1376
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_sshRequired"));
|
|
1090
1377
|
const candidates = config.remote.runtime === "auto" ? ["podman", "docker"] : [config.remote.runtime];
|
|
1091
1378
|
for (const runtime of candidates) {
|
|
1092
1379
|
const versionResult = await ssh.exec(`${runtime} version`, { timeoutMs: 2e4 });
|
|
@@ -1094,7 +1381,7 @@ function createDockerSshProvider(config, localRuntimePref = "auto") {
|
|
|
1094
1381
|
continue;
|
|
1095
1382
|
const composeResult = await ssh.exec(`${runtime} compose version`, { timeoutMs: 2e4 });
|
|
1096
1383
|
if (composeResult.success) {
|
|
1097
|
-
|
|
1384
|
+
logger5.info("Remote container runtime detected", { runtime });
|
|
1098
1385
|
return ok(runtime);
|
|
1099
1386
|
}
|
|
1100
1387
|
}
|
|
@@ -1116,98 +1403,103 @@ function createDockerSshProvider(config, localRuntimePref = "auto") {
|
|
|
1116
1403
|
return false;
|
|
1117
1404
|
}
|
|
1118
1405
|
const deploy2 = async (context) => {
|
|
1119
|
-
const { appDir, projectName, scan, provisionEnv } = context;
|
|
1406
|
+
const { appDir, projectName, scan, provisionEnv, appEnv, options } = context;
|
|
1120
1407
|
const deploymentId = makeDeploymentId();
|
|
1121
|
-
const containerPort = config.expose.containerPort;
|
|
1122
1408
|
const hostPort = config.expose.hostPort;
|
|
1123
|
-
const
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
const
|
|
1128
|
-
const
|
|
1129
|
-
|
|
1130
|
-
const
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1409
|
+
const artifactDir = join(appDir, ".hai", "deploy", options?.environment ?? "prod");
|
|
1410
|
+
mkdirSync(artifactDir, { recursive: true, mode: 448 });
|
|
1411
|
+
chmodSync(artifactDir, 448);
|
|
1412
|
+
const artifact = context.artifact;
|
|
1413
|
+
const resolvedBuildDir = context.artifactDir;
|
|
1414
|
+
const images = artifact.images;
|
|
1415
|
+
const web = images.find((image) => image.name === "web");
|
|
1416
|
+
const app = images.find((image) => image.name === "app");
|
|
1417
|
+
const containerPort = app.containerPort ?? config.expose.containerPort;
|
|
1418
|
+
const url = config.ssh ? `http://${config.ssh.host}:${hostPort}` : `http://SERVER_HOST:${hostPort}`;
|
|
1419
|
+
const { env, sidecar } = buildRuntimeEnv(appDir, projectName, scan, provisionEnv, containerPort, options, appEnv, container?.s3Engine);
|
|
1420
|
+
let webProxy;
|
|
1421
|
+
if (web) {
|
|
1422
|
+
const server = loadDeploymentConfig("serv", join(appDir, "config", "_serv.yml"), env);
|
|
1423
|
+
if (!server.success)
|
|
1424
|
+
return server;
|
|
1425
|
+
const http = server.data.http;
|
|
1426
|
+
const cors = server.data.cors;
|
|
1427
|
+
const apiPrefix = http?.apiPrefix ?? "/api/v1";
|
|
1428
|
+
if (typeof apiPrefix !== "string" || !/^\/[\w/-]+$/.test(apiPrefix))
|
|
1429
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_webProxyInvalid"));
|
|
1430
|
+
webProxy = generateWebProxyConfig(containerPort, apiPrefix, web.containerPort ?? 8080);
|
|
1431
|
+
env.HAI_SERV = JSON.stringify({ ...server.data, cors: { ...cors, origin: (!cors?.origin || cors.origin === "*") && config.ssh ? url : cors?.origin ?? "*" } });
|
|
1142
1432
|
}
|
|
1143
|
-
const buildResult = await buildImage(localRuntime, {
|
|
1144
|
-
contextDir,
|
|
1145
|
-
dockerfilePath,
|
|
1146
|
-
imageRef: localImageTag
|
|
1147
|
-
});
|
|
1148
|
-
if (!buildResult.success)
|
|
1149
|
-
return err(buildResult.error);
|
|
1150
|
-
const { env, sidecar } = buildRuntimeEnv(appDir, projectName, scan, provisionEnv, containerPort);
|
|
1151
1433
|
const envPath = join(artifactDir, ".env.runtime");
|
|
1434
|
+
const previousEnv = existsSync(envPath) ? parseEnv(readFileSync(envPath, "utf8")) : {};
|
|
1435
|
+
Object.assign(env, { ...previousEnv, ...env });
|
|
1152
1436
|
writeFileSync(envPath, serializeEnvFile(env), { mode: 384 });
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
const remoteRuntimeResult = await detectRemoteRuntime();
|
|
1161
|
-
if (!remoteRuntimeResult.success)
|
|
1162
|
-
return err(remoteRuntimeResult.error);
|
|
1163
|
-
const remoteRuntime = remoteRuntimeResult.data;
|
|
1164
|
-
const remoteDir = `${baseDir}/${projectName}`;
|
|
1165
|
-
const prepareResult = await ssh.exec(`mkdir -p ${remoteDir} && chmod 700 ${remoteDir}`);
|
|
1166
|
-
if (!prepareResult.success)
|
|
1167
|
-
return err(prepareResult.error);
|
|
1168
|
-
const remoteTar = `${remoteDir}/image.tar`;
|
|
1169
|
-
const uploadTarResult = await ssh.upload(tarPath, remoteTar);
|
|
1170
|
-
if (!uploadTarResult.success)
|
|
1171
|
-
return err(uploadTarResult.error);
|
|
1172
|
-
const loadResult = await ssh.exec(`${remoteRuntime} load -i ${remoteTar}`, { timeoutMs: 3e5 });
|
|
1173
|
-
if (!loadResult.success)
|
|
1174
|
-
return err(loadResult.error);
|
|
1175
|
-
const loadedRef = parseLoadedImageRef(loadResult.data.stdout, localImageTag);
|
|
1176
|
-
const remoteImageRef = remoteRuntime === "podman" ? `localhost/${localImageTag}` : localImageTag;
|
|
1177
|
-
if (loadedRef !== remoteImageRef) {
|
|
1178
|
-
const tagResult = await ssh.exec(`${remoteRuntime} tag ${loadedRef} ${remoteImageRef}`);
|
|
1179
|
-
if (!tagResult.success)
|
|
1180
|
-
return err(tagResult.error);
|
|
1437
|
+
if (options?.environment) {
|
|
1438
|
+
const environment = loadDeploymentEnvironment({ environment: options.environment, envDir: options.envDir ?? scan.envDir ?? appDir });
|
|
1439
|
+
if (!environment.success)
|
|
1440
|
+
return environment;
|
|
1441
|
+
const saved = saveDeploymentEnvironment(environment.data, { HAI_DEPLOY_ENVIRONMENT: environment.data.environment, HAI_CORE_ENV: environment.data.values.HAI_CORE_ENV, ...sidecar.env, ...provisionEnv });
|
|
1442
|
+
if (!saved.success)
|
|
1443
|
+
return saved;
|
|
1181
1444
|
}
|
|
1445
|
+
const composeName = `docker-compose-${artifact.appVersion}.yml`;
|
|
1446
|
+
const latestPath = join(artifactDir, "latest");
|
|
1447
|
+
const previousName = existsSync(latestPath) ? readFileSync(latestPath, "utf8").trim() : "";
|
|
1448
|
+
const previousPath = /^[\w.+-]+\.yml$/.test(previousName) ? join(artifactDir, previousName) : void 0;
|
|
1182
1449
|
const composeContent = generateComposeFile({
|
|
1183
1450
|
projectName,
|
|
1184
|
-
|
|
1451
|
+
appVersion: artifact.appVersion,
|
|
1452
|
+
imageRef: app.imageRef,
|
|
1185
1453
|
hostPort,
|
|
1186
1454
|
containerPort,
|
|
1187
|
-
sidecar
|
|
1455
|
+
sidecar,
|
|
1456
|
+
web: web ? { imageRef: web.imageRef, containerPort: web.containerPort ?? 8080 } : void 0,
|
|
1457
|
+
serviceModes: options?.serviceModes,
|
|
1458
|
+
previousCompose: previousPath && existsSync(previousPath) ? readFileSync(previousPath, "utf8") : void 0
|
|
1188
1459
|
});
|
|
1189
|
-
const composePath = join(artifactDir,
|
|
1460
|
+
const composePath = join(artifactDir, composeName);
|
|
1190
1461
|
writeFileSync(composePath, composeContent);
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1462
|
+
if (webProxy)
|
|
1463
|
+
writeFileSync(join(artifactDir, "web.conf"), webProxy);
|
|
1464
|
+
const bundle = await createDeploymentBundle({
|
|
1465
|
+
directory: artifactDir,
|
|
1466
|
+
artifactDir: resolvedBuildDir,
|
|
1467
|
+
artifact,
|
|
1468
|
+
projectName,
|
|
1469
|
+
environment: options?.environment ?? "prod",
|
|
1470
|
+
runtime: config.remote.runtime,
|
|
1471
|
+
coreEnv: env.HAI_CORE_ENV ?? "production",
|
|
1472
|
+
output: options?.output ?? join(artifactDir, `bundle-${projectName}-${deploymentId}-${artifact.appVersion}.tar`)
|
|
1473
|
+
});
|
|
1474
|
+
writeFileSync(latestPath, `${composeName}
|
|
1475
|
+
`);
|
|
1476
|
+
if (options?.bundleOnly)
|
|
1477
|
+
return ok({ url: "", deploymentId, status: "packaged", provider: "docker-ssh", healthCheck: "skipped", envVarsSet: Object.keys(env), ...bundle });
|
|
1478
|
+
if (!ssh)
|
|
1479
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_sshRequired"));
|
|
1480
|
+
const ping = await ssh.ping();
|
|
1481
|
+
if (!ping.success)
|
|
1482
|
+
return ping;
|
|
1483
|
+
const remoteRuntime = await detectRemoteRuntime();
|
|
1484
|
+
if (!remoteRuntime.success)
|
|
1485
|
+
return remoteRuntime;
|
|
1486
|
+
const remoteDir = `${baseDir}/${projectName}`;
|
|
1487
|
+
const remoteTar = `${remoteDir}/${basename(bundle.bundlePath)}`;
|
|
1488
|
+
const prepared = await ssh.exec(`mkdir -p -- ${shellArgument(remoteDir)} && chmod 700 -- ${shellArgument(remoteDir)}`);
|
|
1489
|
+
if (!prepared.success)
|
|
1490
|
+
return prepared;
|
|
1491
|
+
const uploaded = await ssh.upload(bundle.bundlePath, remoteTar);
|
|
1492
|
+
if (!uploaded.success)
|
|
1493
|
+
return uploaded;
|
|
1494
|
+
const started = await ssh.exec(
|
|
1495
|
+
`cd ${shellArgument(remoteDir)} && chmod 600 ${shellArgument(remoteTar)} && tar -xf ${shellArgument(remoteTar)} && HAI_DEPLOY_PROVIDER_REMOTE_RUNTIME=${shellArgument(remoteRuntime.data)} sh ${shellArgument(bundle.startName)}`,
|
|
1496
|
+
{ timeoutMs: 3e5, secrets: sidecar.secrets }
|
|
1204
1497
|
);
|
|
1205
|
-
if (!
|
|
1206
|
-
return
|
|
1207
|
-
await ssh.exec(`rm -f ${remoteTar}`);
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
const healthy = await healthCheck(`${url}/`);
|
|
1498
|
+
if (!started.success)
|
|
1499
|
+
return started;
|
|
1500
|
+
await ssh.exec(`rm -f -- ${shellArgument(remoteTar)} ${images.map((image) => shellArgument(`${remoteDir}/images/${image.file}`)).join(" ")}`);
|
|
1501
|
+
logger5.info("Running health check", { url });
|
|
1502
|
+
const healthy = await healthCheck(`${url}${!web && scan.isSvelteKit ? "/" : "/health"}`) && (!web || await healthCheck(`${url}/_hai/web-health`) && await healthCheck(`${url}/`));
|
|
1211
1503
|
if (!healthy) {
|
|
1212
1504
|
return err(
|
|
1213
1505
|
HaiDeployError.HEALTH_CHECK_FAILED,
|
|
@@ -1220,14 +1512,23 @@ function createDockerSshProvider(config, localRuntimePref = "auto") {
|
|
|
1220
1512
|
status: "ready",
|
|
1221
1513
|
provider: "docker-ssh",
|
|
1222
1514
|
healthCheck: "passed",
|
|
1223
|
-
envVarsSet: Object.keys(env)
|
|
1515
|
+
envVarsSet: Object.keys(env),
|
|
1516
|
+
bundlePath: bundle.bundlePath
|
|
1224
1517
|
};
|
|
1225
|
-
|
|
1518
|
+
logger5.info("docker-ssh deployment complete", { url, deploymentId });
|
|
1226
1519
|
return ok(result);
|
|
1227
1520
|
};
|
|
1228
|
-
|
|
1521
|
+
const deploySafely = async (context) => {
|
|
1522
|
+
try {
|
|
1523
|
+
return await deploy2(context);
|
|
1524
|
+
} catch (error) {
|
|
1525
|
+
logger5.error("Deployment bundle failed", { error });
|
|
1526
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_artifactInvalid"), error);
|
|
1527
|
+
}
|
|
1528
|
+
};
|
|
1529
|
+
return { name: "docker-ssh", deploy: deploySafely };
|
|
1229
1530
|
}
|
|
1230
|
-
var
|
|
1531
|
+
var logger6 = core.logger.child({ module: "deploy", scope: "provider-vercel" });
|
|
1231
1532
|
var VERCEL_API = "https://api.vercel.com";
|
|
1232
1533
|
var POLL_INTERVAL = 3e3;
|
|
1233
1534
|
var MAX_POLL_ATTEMPTS = 60;
|
|
@@ -1280,7 +1581,7 @@ function createVercelProvider() {
|
|
|
1280
1581
|
return {
|
|
1281
1582
|
name: "vercel",
|
|
1282
1583
|
async authenticate(apiToken) {
|
|
1283
|
-
|
|
1584
|
+
logger6.debug("Authenticating with Vercel");
|
|
1284
1585
|
try {
|
|
1285
1586
|
const user = await vercelFetch(
|
|
1286
1587
|
apiToken,
|
|
@@ -1288,10 +1589,10 @@ function createVercelProvider() {
|
|
|
1288
1589
|
);
|
|
1289
1590
|
token = apiToken;
|
|
1290
1591
|
const username = user.user.username || user.user.email;
|
|
1291
|
-
|
|
1592
|
+
logger6.info("Vercel authenticated", { user: username });
|
|
1292
1593
|
return ok(username);
|
|
1293
1594
|
} catch (error) {
|
|
1294
|
-
|
|
1595
|
+
logger6.error("Vercel authentication failed", { error });
|
|
1295
1596
|
return err(toDeployError(HaiDeployError.AUTH_FAILED, "deploy_authFailed", error));
|
|
1296
1597
|
}
|
|
1297
1598
|
},
|
|
@@ -1302,11 +1603,11 @@ function createVercelProvider() {
|
|
|
1302
1603
|
deployM("deploy_authRequired")
|
|
1303
1604
|
);
|
|
1304
1605
|
}
|
|
1305
|
-
|
|
1606
|
+
logger6.debug("Creating Vercel project", { projectName });
|
|
1306
1607
|
try {
|
|
1307
1608
|
const existingId = await findExistingProject(token, projectName);
|
|
1308
1609
|
if (existingId !== null) {
|
|
1309
|
-
|
|
1610
|
+
logger6.info("Vercel project found", { projectName, projectId: existingId });
|
|
1310
1611
|
return ok(existingId);
|
|
1311
1612
|
}
|
|
1312
1613
|
try {
|
|
@@ -1321,13 +1622,13 @@ function createVercelProvider() {
|
|
|
1321
1622
|
})
|
|
1322
1623
|
}
|
|
1323
1624
|
);
|
|
1324
|
-
|
|
1625
|
+
logger6.info("Vercel project created", { projectName, projectId: project.id });
|
|
1325
1626
|
return ok(project.id);
|
|
1326
1627
|
} catch (error) {
|
|
1327
1628
|
if (error instanceof VercelApiError && error.status === 409) {
|
|
1328
1629
|
const concurrentProjectId = await findExistingProject(token, projectName);
|
|
1329
1630
|
if (concurrentProjectId !== null) {
|
|
1330
|
-
|
|
1631
|
+
logger6.info("Vercel project reused after create conflict", {
|
|
1331
1632
|
projectName,
|
|
1332
1633
|
projectId: concurrentProjectId
|
|
1333
1634
|
});
|
|
@@ -1337,7 +1638,7 @@ function createVercelProvider() {
|
|
|
1337
1638
|
throw error;
|
|
1338
1639
|
}
|
|
1339
1640
|
} catch (error) {
|
|
1340
|
-
|
|
1641
|
+
logger6.error("Vercel project creation failed", { projectName, error });
|
|
1341
1642
|
return err(toDeployError(HaiDeployError.PROJECT_CREATE_FAILED, "deploy_projectCreateFailed", error));
|
|
1342
1643
|
}
|
|
1343
1644
|
},
|
|
@@ -1348,7 +1649,7 @@ function createVercelProvider() {
|
|
|
1348
1649
|
deployM("deploy_authRequired")
|
|
1349
1650
|
);
|
|
1350
1651
|
}
|
|
1351
|
-
|
|
1652
|
+
logger6.debug("Setting Vercel env vars", { projectId, count: Object.keys(envVars).length });
|
|
1352
1653
|
try {
|
|
1353
1654
|
const envEntries = Object.entries(envVars).map(([key, value]) => ({
|
|
1354
1655
|
key,
|
|
@@ -1364,10 +1665,10 @@ function createVercelProvider() {
|
|
|
1364
1665
|
body: JSON.stringify(envEntries)
|
|
1365
1666
|
}
|
|
1366
1667
|
);
|
|
1367
|
-
|
|
1668
|
+
logger6.info("Vercel env vars set", { projectId, count: envEntries.length });
|
|
1368
1669
|
return ok(void 0);
|
|
1369
1670
|
} catch (error) {
|
|
1370
|
-
|
|
1671
|
+
logger6.error("Vercel env vars setup failed", { projectId, error });
|
|
1371
1672
|
return err(toDeployError(HaiDeployError.ENV_VAR_FAILED, "deploy_envVarFailed", error));
|
|
1372
1673
|
}
|
|
1373
1674
|
},
|
|
@@ -1378,8 +1679,11 @@ function createVercelProvider() {
|
|
|
1378
1679
|
deployM("deploy_authRequired")
|
|
1379
1680
|
);
|
|
1380
1681
|
}
|
|
1381
|
-
|
|
1682
|
+
logger6.debug("Deploying to Vercel", { projectId, outputDir });
|
|
1382
1683
|
try {
|
|
1684
|
+
const outputConfig = JSON.parse(readFileSync(join(outputDir, "config.json"), "utf8"));
|
|
1685
|
+
if (outputConfig.version !== 3)
|
|
1686
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_artifactInvalid"));
|
|
1383
1687
|
const filePaths = collectFiles(outputDir, outputDir);
|
|
1384
1688
|
const files = [];
|
|
1385
1689
|
for (const filePath of filePaths) {
|
|
@@ -1388,18 +1692,20 @@ function createVercelProvider() {
|
|
|
1388
1692
|
const sha = createHash("sha1").update(content).digest("hex");
|
|
1389
1693
|
const size = statSync(fullPath).size;
|
|
1390
1694
|
await uploadFile(token, content, sha);
|
|
1391
|
-
files.push({ file: filePath
|
|
1695
|
+
files.push({ file: `.vercel/output/${filePath}`, sha, size });
|
|
1392
1696
|
}
|
|
1393
|
-
|
|
1697
|
+
logger6.debug("Files uploaded", { count: files.length });
|
|
1394
1698
|
const deployment = await vercelFetch(
|
|
1395
1699
|
token,
|
|
1396
|
-
"/v13/deployments",
|
|
1700
|
+
"/v13/deployments?prebuilt=1",
|
|
1397
1701
|
{
|
|
1398
1702
|
method: "POST",
|
|
1399
1703
|
body: JSON.stringify({
|
|
1400
1704
|
name: projectId,
|
|
1401
1705
|
files,
|
|
1402
|
-
|
|
1706
|
+
version: 2,
|
|
1707
|
+
project: projectId,
|
|
1708
|
+
projectSettings: { framework: null, buildCommand: "", installCommand: "" }
|
|
1403
1709
|
})
|
|
1404
1710
|
}
|
|
1405
1711
|
);
|
|
@@ -1410,14 +1716,14 @@ function createVercelProvider() {
|
|
|
1410
1716
|
status: finalStatus,
|
|
1411
1717
|
envVarsSet: []
|
|
1412
1718
|
};
|
|
1413
|
-
|
|
1719
|
+
logger6.info("Vercel deployment complete", {
|
|
1414
1720
|
deploymentId: deployment.id,
|
|
1415
1721
|
url: result.url,
|
|
1416
1722
|
status: finalStatus
|
|
1417
1723
|
});
|
|
1418
1724
|
return ok(result);
|
|
1419
1725
|
} catch (error) {
|
|
1420
|
-
|
|
1726
|
+
logger6.error("Vercel deployment failed", { projectId, error });
|
|
1421
1727
|
return err(toDeployError(HaiDeployError.UPLOAD_FAILED, "deploy_uploadFailed", error));
|
|
1422
1728
|
}
|
|
1423
1729
|
}
|
|
@@ -1462,11 +1768,11 @@ async function pollDeploymentStatus(token, deploymentId) {
|
|
|
1462
1768
|
if (deployment.readyState === "ERROR" || deployment.readyState === "CANCELED") {
|
|
1463
1769
|
return "error";
|
|
1464
1770
|
}
|
|
1465
|
-
await new Promise((
|
|
1771
|
+
await new Promise((resolve6) => setTimeout(resolve6, POLL_INTERVAL));
|
|
1466
1772
|
}
|
|
1467
1773
|
return "building";
|
|
1468
1774
|
}
|
|
1469
|
-
var
|
|
1775
|
+
var logger7 = core.logger.child({ module: "deploy", scope: "provisioner-aliyun" });
|
|
1470
1776
|
function createAliyunProvisioner() {
|
|
1471
1777
|
let accessKey = null;
|
|
1472
1778
|
let secretKey = null;
|
|
@@ -1475,7 +1781,7 @@ function createAliyunProvisioner() {
|
|
|
1475
1781
|
name: "aliyun",
|
|
1476
1782
|
serviceType: "sms",
|
|
1477
1783
|
async authenticate(credentials) {
|
|
1478
|
-
|
|
1784
|
+
logger7.debug("Authenticating with Aliyun");
|
|
1479
1785
|
try {
|
|
1480
1786
|
const akId = credentials.accessKeyId ?? credentials.access_key_id ?? credentials.access_key ?? "";
|
|
1481
1787
|
const akSecret = credentials.accessKeySecret ?? credentials.access_key_secret ?? credentials.secret_key ?? "";
|
|
@@ -1485,10 +1791,10 @@ function createAliyunProvisioner() {
|
|
|
1485
1791
|
accessKey = akId;
|
|
1486
1792
|
secretKey = akSecret;
|
|
1487
1793
|
signName = credentials.signName;
|
|
1488
|
-
|
|
1794
|
+
logger7.info("Aliyun authenticated", { accessKeyId: `${akId.slice(0, 6)}***` });
|
|
1489
1795
|
return ok(akId);
|
|
1490
1796
|
} catch (error) {
|
|
1491
|
-
|
|
1797
|
+
logger7.error("Aliyun authentication failed", { error });
|
|
1492
1798
|
return err(
|
|
1493
1799
|
HaiDeployError.AUTH_FAILED,
|
|
1494
1800
|
deployM("deploy_authFailed", {
|
|
@@ -1505,7 +1811,7 @@ function createAliyunProvisioner() {
|
|
|
1505
1811
|
deployM("deploy_authRequired")
|
|
1506
1812
|
);
|
|
1507
1813
|
}
|
|
1508
|
-
|
|
1814
|
+
logger7.debug("Provisioning Aliyun SMS service");
|
|
1509
1815
|
return ok({
|
|
1510
1816
|
serviceType: "sms",
|
|
1511
1817
|
provisionerName: "aliyun",
|
|
@@ -1518,7 +1824,7 @@ function createAliyunProvisioner() {
|
|
|
1518
1824
|
}
|
|
1519
1825
|
};
|
|
1520
1826
|
}
|
|
1521
|
-
var
|
|
1827
|
+
var logger8 = core.logger.child({ module: "deploy", scope: "provisioner-neon" });
|
|
1522
1828
|
var NEON_API = "https://console.neon.tech/api/v2";
|
|
1523
1829
|
var DEFAULT_NEON_DATABASE_NAME = "neondb";
|
|
1524
1830
|
var DEFAULT_NEON_ROLE_NAME = "neondb_owner";
|
|
@@ -1577,7 +1883,7 @@ function createNeonProvisioner() {
|
|
|
1577
1883
|
name: "neon",
|
|
1578
1884
|
serviceType: "db",
|
|
1579
1885
|
async authenticate(credentials) {
|
|
1580
|
-
|
|
1886
|
+
logger8.debug("Authenticating with Neon");
|
|
1581
1887
|
try {
|
|
1582
1888
|
const apiToken = credentials.apiKey ?? credentials.token ?? credentials.api_key ?? "";
|
|
1583
1889
|
if (!apiToken) {
|
|
@@ -1585,10 +1891,10 @@ function createNeonProvisioner() {
|
|
|
1585
1891
|
}
|
|
1586
1892
|
await listNeonProjects(apiToken);
|
|
1587
1893
|
token = apiToken;
|
|
1588
|
-
|
|
1894
|
+
logger8.info("Neon authenticated");
|
|
1589
1895
|
return ok("neon-user");
|
|
1590
1896
|
} catch (error) {
|
|
1591
|
-
|
|
1897
|
+
logger8.error("Neon authentication failed", { error });
|
|
1592
1898
|
return err(
|
|
1593
1899
|
HaiDeployError.AUTH_FAILED,
|
|
1594
1900
|
deployM("deploy_authFailed", {
|
|
@@ -1605,7 +1911,7 @@ function createNeonProvisioner() {
|
|
|
1605
1911
|
deployM("deploy_authRequired")
|
|
1606
1912
|
);
|
|
1607
1913
|
}
|
|
1608
|
-
|
|
1914
|
+
logger8.debug("Provisioning Neon database", { appName });
|
|
1609
1915
|
let resource;
|
|
1610
1916
|
try {
|
|
1611
1917
|
const projectName = `${appName}-db`;
|
|
@@ -1613,7 +1919,7 @@ function createNeonProvisioner() {
|
|
|
1613
1919
|
if (existingProject !== null) {
|
|
1614
1920
|
resource = { resourceInfo: `neon-project:${existingProject.id}`, resourceStatus: "reused" };
|
|
1615
1921
|
const connectionUri2 = await getNeonConnectionUri(token, existingProject.id);
|
|
1616
|
-
|
|
1922
|
+
logger8.info("Neon project reused", {
|
|
1617
1923
|
projectId: existingProject.id,
|
|
1618
1924
|
projectName
|
|
1619
1925
|
});
|
|
@@ -1635,10 +1941,10 @@ function createNeonProvisioner() {
|
|
|
1635
1941
|
if (!projectId || !connectionUri) {
|
|
1636
1942
|
throw new Error(deployM("deploy_provisionNoResult", { params: { service: "Neon" } }));
|
|
1637
1943
|
}
|
|
1638
|
-
|
|
1944
|
+
logger8.info("Neon database provisioned", { projectId });
|
|
1639
1945
|
return ok(buildNeonProvisionResult(projectId, connectionUri, "created"));
|
|
1640
1946
|
} catch (error) {
|
|
1641
|
-
|
|
1947
|
+
logger8.error("Neon provisioning failed", { appName });
|
|
1642
1948
|
if (resource) {
|
|
1643
1949
|
return recoveryFailure({ ...HaiDeployError.PROVISION_FAILED}, {
|
|
1644
1950
|
projectName: appName,
|
|
@@ -1660,7 +1966,7 @@ function createNeonProvisioner() {
|
|
|
1660
1966
|
}
|
|
1661
1967
|
};
|
|
1662
1968
|
}
|
|
1663
|
-
var
|
|
1969
|
+
var logger9 = core.logger.child({ module: "deploy", scope: "provisioner-r2" });
|
|
1664
1970
|
var CF_API = "https://api.cloudflare.com/client/v4";
|
|
1665
1971
|
function createR2Provisioner() {
|
|
1666
1972
|
let token = null;
|
|
@@ -1671,7 +1977,7 @@ function createR2Provisioner() {
|
|
|
1671
1977
|
name: "r2",
|
|
1672
1978
|
serviceType: "storage",
|
|
1673
1979
|
async authenticate(credentials) {
|
|
1674
|
-
|
|
1980
|
+
logger9.debug("Authenticating with Cloudflare");
|
|
1675
1981
|
try {
|
|
1676
1982
|
const acctId = credentials.accountId ?? credentials.account_id ?? "";
|
|
1677
1983
|
const apiTok = credentials.apiToken ?? credentials.api_token ?? credentials.token ?? "";
|
|
@@ -1688,10 +1994,10 @@ function createR2Provisioner() {
|
|
|
1688
1994
|
token = apiTok;
|
|
1689
1995
|
accessKey = credentials.accessKeyId;
|
|
1690
1996
|
secretKey = credentials.secretAccessKey;
|
|
1691
|
-
|
|
1997
|
+
logger9.info("Cloudflare R2 authenticated", { accountId: acctId });
|
|
1692
1998
|
return ok(acctId);
|
|
1693
1999
|
} catch (error) {
|
|
1694
|
-
|
|
2000
|
+
logger9.error("Cloudflare R2 authentication failed", { error });
|
|
1695
2001
|
return err(
|
|
1696
2002
|
HaiDeployError.AUTH_FAILED,
|
|
1697
2003
|
deployM("deploy_authFailed", {
|
|
@@ -1709,7 +2015,7 @@ function createR2Provisioner() {
|
|
|
1709
2015
|
);
|
|
1710
2016
|
}
|
|
1711
2017
|
const bucketName = `${appName}-storage`.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
1712
|
-
|
|
2018
|
+
logger9.debug("Provisioning R2 bucket", { appName, bucketName });
|
|
1713
2019
|
try {
|
|
1714
2020
|
const createRes = await fetch(
|
|
1715
2021
|
`${CF_API}/accounts/${accountId}/r2/buckets`,
|
|
@@ -1725,7 +2031,7 @@ function createR2Provisioner() {
|
|
|
1725
2031
|
if (!createRes.ok && createRes.status !== 409) {
|
|
1726
2032
|
throw new Error(deployM("deploy_apiError", { params: { service: "Cloudflare", status: String(createRes.status) } }));
|
|
1727
2033
|
}
|
|
1728
|
-
|
|
2034
|
+
logger9.info("R2 bucket provisioned", { bucketName });
|
|
1729
2035
|
return ok({
|
|
1730
2036
|
serviceType: "storage",
|
|
1731
2037
|
provisionerName: "r2",
|
|
@@ -1743,7 +2049,7 @@ function createR2Provisioner() {
|
|
|
1743
2049
|
resourceInfo: `r2-bucket:${bucketName}`
|
|
1744
2050
|
});
|
|
1745
2051
|
} catch (error) {
|
|
1746
|
-
|
|
2052
|
+
logger9.error("R2 provisioning failed", { appName, error });
|
|
1747
2053
|
return err(
|
|
1748
2054
|
HaiDeployError.PROVISION_FAILED,
|
|
1749
2055
|
deployM("deploy_provisionFailed", {
|
|
@@ -1758,7 +2064,7 @@ function createR2Provisioner() {
|
|
|
1758
2064
|
}
|
|
1759
2065
|
};
|
|
1760
2066
|
}
|
|
1761
|
-
var
|
|
2067
|
+
var logger10 = core.logger.child({ module: "deploy", scope: "provisioner-resend" });
|
|
1762
2068
|
var RESEND_API = "https://api.resend.com";
|
|
1763
2069
|
function createResendProvisioner() {
|
|
1764
2070
|
let token = null;
|
|
@@ -1767,7 +2073,7 @@ function createResendProvisioner() {
|
|
|
1767
2073
|
name: "resend",
|
|
1768
2074
|
serviceType: "email",
|
|
1769
2075
|
async authenticate(credentials) {
|
|
1770
|
-
|
|
2076
|
+
logger10.debug("Authenticating with Resend");
|
|
1771
2077
|
try {
|
|
1772
2078
|
const apiToken = credentials.apiKey ?? credentials.api_key ?? credentials.token ?? "";
|
|
1773
2079
|
if (!apiToken || !credentials.from) {
|
|
@@ -1781,10 +2087,10 @@ function createResendProvisioner() {
|
|
|
1781
2087
|
}
|
|
1782
2088
|
token = apiToken;
|
|
1783
2089
|
from = credentials.from;
|
|
1784
|
-
|
|
2090
|
+
logger10.info("Resend authenticated");
|
|
1785
2091
|
return ok("resend-user");
|
|
1786
2092
|
} catch (error) {
|
|
1787
|
-
|
|
2093
|
+
logger10.error("Resend authentication failed", { error });
|
|
1788
2094
|
return err(
|
|
1789
2095
|
HaiDeployError.AUTH_FAILED,
|
|
1790
2096
|
deployM("deploy_authFailed", {
|
|
@@ -1801,7 +2107,7 @@ function createResendProvisioner() {
|
|
|
1801
2107
|
deployM("deploy_authRequired")
|
|
1802
2108
|
);
|
|
1803
2109
|
}
|
|
1804
|
-
|
|
2110
|
+
logger10.debug("Provisioning Resend email service");
|
|
1805
2111
|
return ok({
|
|
1806
2112
|
serviceType: "email",
|
|
1807
2113
|
provisionerName: "resend",
|
|
@@ -1814,7 +2120,7 @@ function createResendProvisioner() {
|
|
|
1814
2120
|
}
|
|
1815
2121
|
};
|
|
1816
2122
|
}
|
|
1817
|
-
var
|
|
2123
|
+
var logger11 = core.logger.child({ module: "deploy", scope: "provisioner-upstash" });
|
|
1818
2124
|
var UPSTASH_API = "https://api.upstash.com";
|
|
1819
2125
|
function createUpstashAuthHeader(email, apiKey) {
|
|
1820
2126
|
return `Basic ${Buffer.from(`${email}:${apiKey}`).toString("base64")}`;
|
|
@@ -1865,7 +2171,7 @@ function createUpstashProvisioner() {
|
|
|
1865
2171
|
name: "upstash",
|
|
1866
2172
|
serviceType: "cache",
|
|
1867
2173
|
async authenticate(credentials) {
|
|
1868
|
-
|
|
2174
|
+
logger11.debug("Authenticating with Upstash");
|
|
1869
2175
|
try {
|
|
1870
2176
|
const userEmail = credentials.email ?? "";
|
|
1871
2177
|
const userKey = credentials.apiKey ?? credentials.api_key ?? credentials.token ?? "";
|
|
@@ -1875,10 +2181,10 @@ function createUpstashProvisioner() {
|
|
|
1875
2181
|
await listUpstashDatabases(userEmail, userKey);
|
|
1876
2182
|
email = userEmail;
|
|
1877
2183
|
apiKey = userKey;
|
|
1878
|
-
|
|
2184
|
+
logger11.info("Upstash authenticated", { email: userEmail });
|
|
1879
2185
|
return ok(userEmail);
|
|
1880
2186
|
} catch (error) {
|
|
1881
|
-
|
|
2187
|
+
logger11.error("Upstash authentication failed", { error });
|
|
1882
2188
|
return err(
|
|
1883
2189
|
HaiDeployError.AUTH_FAILED,
|
|
1884
2190
|
deployM("deploy_authFailed", {
|
|
@@ -1895,7 +2201,7 @@ function createUpstashProvisioner() {
|
|
|
1895
2201
|
deployM("deploy_authRequired")
|
|
1896
2202
|
);
|
|
1897
2203
|
}
|
|
1898
|
-
|
|
2204
|
+
logger11.debug("Provisioning Upstash Redis", { appName });
|
|
1899
2205
|
let resource;
|
|
1900
2206
|
try {
|
|
1901
2207
|
const databaseName = `${appName}-cache`;
|
|
@@ -1907,7 +2213,7 @@ function createUpstashProvisioner() {
|
|
|
1907
2213
|
}
|
|
1908
2214
|
resource = { resourceInfo: `upstash-db:${databaseId}`, resourceStatus: "reused" };
|
|
1909
2215
|
const detail = await getUpstashDatabase(email, apiKey, databaseId);
|
|
1910
|
-
|
|
2216
|
+
logger11.info("Upstash Redis reused", { databaseId, databaseName });
|
|
1911
2217
|
return ok(buildUpstashProvisionResult({ ...existingDatabase, ...detail }, "reused"));
|
|
1912
2218
|
}
|
|
1913
2219
|
const data = await upstashFetch(email, apiKey, "/v2/redis/database", {
|
|
@@ -1920,10 +2226,10 @@ function createUpstashProvisioner() {
|
|
|
1920
2226
|
});
|
|
1921
2227
|
if (data.database_id)
|
|
1922
2228
|
resource = { resourceInfo: `upstash-db:${data.database_id}`, resourceStatus: "created" };
|
|
1923
|
-
|
|
2229
|
+
logger11.info("Upstash Redis provisioned", { databaseId: data.database_id });
|
|
1924
2230
|
return ok(buildUpstashProvisionResult(data, "created"));
|
|
1925
2231
|
} catch (error) {
|
|
1926
|
-
|
|
2232
|
+
logger11.error("Upstash provisioning failed", { appName });
|
|
1927
2233
|
if (resource) {
|
|
1928
2234
|
return recoveryFailure({ ...HaiDeployError.PROVISION_FAILED}, {
|
|
1929
2235
|
projectName: appName,
|
|
@@ -1947,7 +2253,7 @@ function createUpstashProvisioner() {
|
|
|
1947
2253
|
}
|
|
1948
2254
|
|
|
1949
2255
|
// src/deploy-main.ts
|
|
1950
|
-
var
|
|
2256
|
+
var logger12 = core.logger.child({ module: "deploy", scope: "main" });
|
|
1951
2257
|
var currentConfig = null;
|
|
1952
2258
|
var currentProvider = null;
|
|
1953
2259
|
var currentContainerProvider = null;
|
|
@@ -2000,15 +2306,31 @@ function normalizeProjectName(name) {
|
|
|
2000
2306
|
const normalized = name.replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2001
2307
|
return normalized || "app";
|
|
2002
2308
|
}
|
|
2003
|
-
function mergeProvisionEnv(results) {
|
|
2309
|
+
function mergeProvisionEnv(results, scan, options) {
|
|
2004
2310
|
let env = {};
|
|
2005
|
-
const
|
|
2311
|
+
const reachRequirements = scan?.middlewares?.filter((item) => item.configName === "reach") ?? [];
|
|
2312
|
+
const reachProviders = reachRequirements.map((item) => item.config);
|
|
2313
|
+
let hasReachOutput = false;
|
|
2006
2314
|
for (const prov of results) {
|
|
2007
|
-
if (prov.envVars.HAI_REACH_PROVIDERS)
|
|
2008
|
-
|
|
2315
|
+
if (prov.envVars.HAI_REACH_PROVIDERS) {
|
|
2316
|
+
hasReachOutput = true;
|
|
2317
|
+
const supplied = JSON.parse(prov.envVars.HAI_REACH_PROVIDERS);
|
|
2318
|
+
for (const provider of supplied) {
|
|
2319
|
+
const targets = reachRequirements.filter((item) => {
|
|
2320
|
+
const mode = options?.serviceModes?.[item.envPrefix] ?? options?.serviceModes?.[item.service];
|
|
2321
|
+
return item.service === prov.serviceType && (!mode || mode === "default");
|
|
2322
|
+
});
|
|
2323
|
+
if (targets.length) {
|
|
2324
|
+
for (const target of targets)
|
|
2325
|
+
reachProviders[reachRequirements.indexOf(target)] = { ...provider, name: target.config.name ?? provider.name };
|
|
2326
|
+
} else {
|
|
2327
|
+
reachProviders.push(provider);
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2009
2331
|
env = { ...env, ...prov.envVars };
|
|
2010
2332
|
}
|
|
2011
|
-
if (
|
|
2333
|
+
if (hasReachOutput)
|
|
2012
2334
|
env.HAI_REACH_PROVIDERS = JSON.stringify(reachProviders);
|
|
2013
2335
|
return env;
|
|
2014
2336
|
}
|
|
@@ -2016,7 +2338,7 @@ var deploy = {
|
|
|
2016
2338
|
credentials: deployCredentials,
|
|
2017
2339
|
async init(config) {
|
|
2018
2340
|
if (initInProgress) {
|
|
2019
|
-
|
|
2341
|
+
logger12.warn("Deploy init already in progress, skipping concurrent call");
|
|
2020
2342
|
return err(
|
|
2021
2343
|
HaiDeployError.CONFIG_ERROR,
|
|
2022
2344
|
deployM("deploy_configError", { params: { error: "init already in progress" } })
|
|
@@ -2025,13 +2347,13 @@ var deploy = {
|
|
|
2025
2347
|
initInProgress = true;
|
|
2026
2348
|
try {
|
|
2027
2349
|
if (currentProvider || currentContainerProvider) {
|
|
2028
|
-
|
|
2350
|
+
logger12.warn("Deploy module is already initialized, reinitializing");
|
|
2029
2351
|
await deploy.close();
|
|
2030
2352
|
}
|
|
2031
|
-
|
|
2353
|
+
logger12.info("Initializing deploy module");
|
|
2032
2354
|
const parseResult = DeployConfigSchema.safeParse(config);
|
|
2033
2355
|
if (!parseResult.success) {
|
|
2034
|
-
|
|
2356
|
+
logger12.error("Deploy config validation failed", { error: parseResult.error.message });
|
|
2035
2357
|
return err(
|
|
2036
2358
|
HaiDeployError.CONFIG_ERROR,
|
|
2037
2359
|
deployM("deploy_configError", { params: { error: parseResult.error.message } }),
|
|
@@ -2043,15 +2365,15 @@ var deploy = {
|
|
|
2043
2365
|
let provider = null;
|
|
2044
2366
|
let containerProvider = null;
|
|
2045
2367
|
if (parsed.provider.type === "docker-ssh") {
|
|
2046
|
-
containerProvider = createDockerSshProvider(parsed.provider, parsed.container
|
|
2047
|
-
|
|
2368
|
+
containerProvider = createDockerSshProvider(parsed.provider, parsed.container);
|
|
2369
|
+
logger12.info("Container deploy provider ready", { provider: "docker-ssh", host: parsed.provider.ssh?.host });
|
|
2048
2370
|
} else {
|
|
2049
2371
|
provider = createProviderByType(parsed.provider.type);
|
|
2050
2372
|
const authResult = await provider.authenticate(parsed.provider.token);
|
|
2051
2373
|
if (!authResult.success) {
|
|
2052
2374
|
return err(authResult.error);
|
|
2053
2375
|
}
|
|
2054
|
-
|
|
2376
|
+
logger12.info("Provider authenticated", { provider: parsed.provider.type, user: authResult.data });
|
|
2055
2377
|
}
|
|
2056
2378
|
currentProvisioners.clear();
|
|
2057
2379
|
if (parsed.services) {
|
|
@@ -2063,14 +2385,14 @@ var deploy = {
|
|
|
2063
2385
|
const credentials = extractCredentials(serviceConfig);
|
|
2064
2386
|
const provAuthResult = await provisioner.authenticate(credentials);
|
|
2065
2387
|
if (!provAuthResult.success) {
|
|
2066
|
-
|
|
2388
|
+
logger12.error("Provisioner authentication failed", {
|
|
2067
2389
|
service: serviceType,
|
|
2068
2390
|
provisioner: serviceConfig.provisioner
|
|
2069
2391
|
});
|
|
2070
2392
|
currentProvisioners.clear();
|
|
2071
2393
|
return err(provAuthResult.error);
|
|
2072
2394
|
}
|
|
2073
|
-
|
|
2395
|
+
logger12.info("Provisioner authenticated", {
|
|
2074
2396
|
service: serviceType,
|
|
2075
2397
|
user: provAuthResult.data
|
|
2076
2398
|
});
|
|
@@ -2080,7 +2402,7 @@ var deploy = {
|
|
|
2080
2402
|
currentProvider = provider;
|
|
2081
2403
|
currentContainerProvider = containerProvider;
|
|
2082
2404
|
currentConfig = parsed;
|
|
2083
|
-
|
|
2405
|
+
logger12.info("Deploy module initialized", {
|
|
2084
2406
|
provider: parsed.provider.type,
|
|
2085
2407
|
provisioners: [...currentProvisioners.keys()]
|
|
2086
2408
|
});
|
|
@@ -2090,7 +2412,7 @@ var deploy = {
|
|
|
2090
2412
|
currentContainerProvider = null;
|
|
2091
2413
|
currentConfig = null;
|
|
2092
2414
|
currentProvisioners.clear();
|
|
2093
|
-
|
|
2415
|
+
logger12.error("Deploy module initialization failed", { error });
|
|
2094
2416
|
return err(
|
|
2095
2417
|
HaiDeployError.CONFIG_ERROR,
|
|
2096
2418
|
deployM("deploy_configError", {
|
|
@@ -2105,15 +2427,15 @@ var deploy = {
|
|
|
2105
2427
|
},
|
|
2106
2428
|
async close() {
|
|
2107
2429
|
if (!currentProvider && !currentContainerProvider) {
|
|
2108
|
-
|
|
2430
|
+
logger12.info("Deploy module already closed, skipping");
|
|
2109
2431
|
return;
|
|
2110
2432
|
}
|
|
2111
|
-
|
|
2433
|
+
logger12.info("Closing deploy module");
|
|
2112
2434
|
currentProvider = null;
|
|
2113
2435
|
currentContainerProvider = null;
|
|
2114
2436
|
currentConfig = null;
|
|
2115
2437
|
currentProvisioners.clear();
|
|
2116
|
-
|
|
2438
|
+
logger12.info("Deploy module closed");
|
|
2117
2439
|
},
|
|
2118
2440
|
get config() {
|
|
2119
2441
|
return currentConfig;
|
|
@@ -2121,21 +2443,23 @@ var deploy = {
|
|
|
2121
2443
|
get isInitialized() {
|
|
2122
2444
|
return currentProvider !== null || currentContainerProvider !== null;
|
|
2123
2445
|
},
|
|
2124
|
-
async scan(appDir) {
|
|
2125
|
-
|
|
2126
|
-
return scanApp(appDir);
|
|
2446
|
+
async scan(appDir, options) {
|
|
2447
|
+
logger12.debug("Scanning application", { appDir });
|
|
2448
|
+
return scanApp(appDir, options);
|
|
2127
2449
|
},
|
|
2128
|
-
async provisionAll(projectName) {
|
|
2450
|
+
async provisionAll(projectName, services) {
|
|
2129
2451
|
if (!currentProvider && !currentContainerProvider) {
|
|
2130
2452
|
return notInitialized.result();
|
|
2131
2453
|
}
|
|
2132
|
-
|
|
2454
|
+
logger12.info("Provisioning all services", { projectName, count: currentProvisioners.size });
|
|
2133
2455
|
const results = [];
|
|
2134
2456
|
for (const [serviceType, provisioner] of currentProvisioners) {
|
|
2135
|
-
|
|
2457
|
+
if (services && !services.includes(serviceType))
|
|
2458
|
+
continue;
|
|
2459
|
+
logger12.debug("Provisioning service", { serviceType, provisioner: provisioner.name });
|
|
2136
2460
|
const provResult = await provisioner.provision(projectName);
|
|
2137
2461
|
if (!provResult.success) {
|
|
2138
|
-
|
|
2462
|
+
logger12.error("Service provisioning failed", {
|
|
2139
2463
|
serviceType,
|
|
2140
2464
|
provisioner: provisioner.name,
|
|
2141
2465
|
code: provResult.error.code
|
|
@@ -2152,7 +2476,7 @@ var deploy = {
|
|
|
2152
2476
|
});
|
|
2153
2477
|
}
|
|
2154
2478
|
results.push(provResult.data);
|
|
2155
|
-
|
|
2479
|
+
logger12.info("Service provisioned", { serviceType, provisioner: provisioner.name });
|
|
2156
2480
|
}
|
|
2157
2481
|
return ok(results);
|
|
2158
2482
|
},
|
|
@@ -2160,16 +2484,29 @@ var deploy = {
|
|
|
2160
2484
|
if (!currentProvider && !currentContainerProvider) {
|
|
2161
2485
|
return notInitialized.result();
|
|
2162
2486
|
}
|
|
2163
|
-
|
|
2164
|
-
const
|
|
2487
|
+
logger12.info("Starting deployment", { appDir, environment: options?.environment, projectName: options?.projectName });
|
|
2488
|
+
const scanOptions = { ...options, environment: options?.environment ?? currentConfig?.environment };
|
|
2489
|
+
const scanResult = await scanApp(appDir, scanOptions);
|
|
2165
2490
|
if (!scanResult.success) {
|
|
2166
2491
|
return err(scanResult.error);
|
|
2167
2492
|
}
|
|
2168
2493
|
const scan = scanResult.data;
|
|
2169
|
-
|
|
2494
|
+
appDir = scan.appDir ?? appDir;
|
|
2495
|
+
const environment = loadDeploymentEnvironment({ environment: scanOptions.environment ?? "prod", envDir: scan.envDir ?? appDir, requireFile: scanOptions.environment !== void 0 });
|
|
2496
|
+
if (!environment.success)
|
|
2497
|
+
return environment;
|
|
2498
|
+
const appEnv = Object.fromEntries(Object.entries(environment.data.values).filter(([key]) => !key.startsWith("HAI_CLI_") && (!key.startsWith("HAI_DEPLOY_") || key === "HAI_DEPLOY_ENVIRONMENT")));
|
|
2499
|
+
const projectName = `${options?.projectName ?? scan.appName}${scanOptions.environment ? `-${environment.data.environment}` : ""}`;
|
|
2500
|
+
options = {
|
|
2501
|
+
...options,
|
|
2502
|
+
...scanOptions.environment ? { environment: environment.data.environment } : {},
|
|
2503
|
+
...options?.skipMiddleware ? { serviceModes: Object.fromEntries(scan.requiredServices.map((service) => [service, "keep"])) } : {}
|
|
2504
|
+
};
|
|
2170
2505
|
if (currentContainerProvider) {
|
|
2171
|
-
return deployContainerApp(currentContainerProvider, appDir, scan, projectName, options);
|
|
2506
|
+
return deployContainerApp(currentContainerProvider, appDir, scan, projectName, options, appEnv);
|
|
2172
2507
|
}
|
|
2508
|
+
if (options?.bundleOnly)
|
|
2509
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_bundleOnlyContainer"));
|
|
2173
2510
|
if (!currentProvider) {
|
|
2174
2511
|
return notInitialized.result();
|
|
2175
2512
|
}
|
|
@@ -2181,15 +2518,22 @@ var deploy = {
|
|
|
2181
2518
|
})
|
|
2182
2519
|
);
|
|
2183
2520
|
}
|
|
2184
|
-
|
|
2521
|
+
if (!existsSync(resolveOutputDir(appDir, "vercel")))
|
|
2522
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_artifactInvalid"));
|
|
2523
|
+
let allEnvVars = appEnv;
|
|
2185
2524
|
const recovery = { projectName, stage: "provision", resources: [] };
|
|
2186
|
-
if (!options?.
|
|
2187
|
-
const provResults = await deploy.provisionAll(projectName);
|
|
2525
|
+
if (!options?.skipMiddleware) {
|
|
2526
|
+
const provResults = await deploy.provisionAll(projectName, options?.serviceModes ? scan.requiredServices.filter((service) => usesDefaultMiddleware(scan, service, options)) : void 0);
|
|
2188
2527
|
if (!provResults.success) {
|
|
2189
2528
|
return err(provResults.error);
|
|
2190
2529
|
}
|
|
2191
2530
|
recovery.resources = provResults.data.map(({ envVars: _envVars, ...resource }) => resource);
|
|
2192
|
-
allEnvVars = mergeProvisionEnv(provResults.data);
|
|
2531
|
+
allEnvVars = { ...allEnvVars, ...mergeProvisionEnv(provResults.data, scan, options) };
|
|
2532
|
+
if (options?.environment) {
|
|
2533
|
+
const saved = saveDeploymentEnvironment(environment.data, mergeProvisionEnv(provResults.data, scan, options));
|
|
2534
|
+
if (!saved.success)
|
|
2535
|
+
return recoveryFailure(saved.error, { ...recovery, stage: "save-env" });
|
|
2536
|
+
}
|
|
2193
2537
|
}
|
|
2194
2538
|
const projectResult = await currentProvider.createProject(projectName);
|
|
2195
2539
|
if (!projectResult.success) {
|
|
@@ -2203,12 +2547,6 @@ var deploy = {
|
|
|
2203
2547
|
return recoveryFailure(envResult.error, { ...recovery, stage: "set-env" });
|
|
2204
2548
|
}
|
|
2205
2549
|
}
|
|
2206
|
-
if (!options?.skipBuild) {
|
|
2207
|
-
const buildResult = buildApp(appDir, scan.buildCommand, allEnvVars);
|
|
2208
|
-
if (!buildResult.success) {
|
|
2209
|
-
return recoveryFailure(buildResult.error, { ...recovery, stage: "build" });
|
|
2210
|
-
}
|
|
2211
|
-
}
|
|
2212
2550
|
const outputDir = resolveOutputDir(appDir, currentConfig?.provider.type ?? "vercel");
|
|
2213
2551
|
const deployResult = await currentProvider.deploy(projectId, outputDir);
|
|
2214
2552
|
if (!deployResult.success) {
|
|
@@ -2218,7 +2556,7 @@ var deploy = {
|
|
|
2218
2556
|
...deployResult.data,
|
|
2219
2557
|
envVarsSet: Object.keys(allEnvVars)
|
|
2220
2558
|
};
|
|
2221
|
-
|
|
2559
|
+
logger12.info("Deployment completed", {
|
|
2222
2560
|
url: result.url,
|
|
2223
2561
|
deploymentId: result.deploymentId,
|
|
2224
2562
|
status: result.status
|
|
@@ -2226,25 +2564,79 @@ var deploy = {
|
|
|
2226
2564
|
return ok(result);
|
|
2227
2565
|
}
|
|
2228
2566
|
};
|
|
2229
|
-
async function deployContainerApp(provider, appDir, scan, rawProjectName, options) {
|
|
2567
|
+
async function deployContainerApp(provider, appDir, scan, rawProjectName, options, appEnv = {}) {
|
|
2230
2568
|
const projectName = normalizeProjectName(rawProjectName);
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2569
|
+
let artifact;
|
|
2570
|
+
let artifactDir = options?.artifactDir ?? resolveArtifactDirectory(appDir);
|
|
2571
|
+
try {
|
|
2572
|
+
if (existsSync(join(artifactDir, "latest"))) {
|
|
2573
|
+
const latest = readFileSync(join(artifactDir, "latest"), "utf8").trim();
|
|
2574
|
+
if (!/^[a-z0-9][\w.+-]*$/i.test(latest))
|
|
2575
|
+
throw new Error("Invalid artifact pointer");
|
|
2576
|
+
artifactDir = join(artifactDir, latest);
|
|
2577
|
+
}
|
|
2578
|
+
artifact = await readDeploymentArtifact(artifactDir);
|
|
2579
|
+
} catch {
|
|
2580
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_artifactInvalid"));
|
|
2236
2581
|
}
|
|
2582
|
+
if (!options?.bundleOnly && currentConfig?.provider.type === "docker-ssh" && !currentConfig.provider.ssh)
|
|
2583
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_sshRequired"));
|
|
2237
2584
|
let provisionEnv = {};
|
|
2238
|
-
if (!options?.
|
|
2239
|
-
const provResults = await deploy.provisionAll(projectName);
|
|
2585
|
+
if (!options?.skipMiddleware) {
|
|
2586
|
+
const provResults = await deploy.provisionAll(projectName, options?.serviceModes ? scan.requiredServices.filter((service) => usesDefaultMiddleware(scan, service, options)) : void 0);
|
|
2240
2587
|
if (!provResults.success) {
|
|
2241
2588
|
return err(provResults.error);
|
|
2242
2589
|
}
|
|
2243
|
-
provisionEnv = mergeProvisionEnv(provResults.data);
|
|
2590
|
+
provisionEnv = mergeProvisionEnv(provResults.data, scan, options);
|
|
2244
2591
|
}
|
|
2245
|
-
return provider.deploy({ appDir, projectName, scan, provisionEnv });
|
|
2592
|
+
return provider.deploy({ appDir, artifact, artifactDir, projectName, scan, provisionEnv, appEnv, options });
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
// src/deploy-middleware.ts
|
|
2596
|
+
var field = (name, defaultValue, kind, secret = false) => ({ name, defaultValue, kind, secret });
|
|
2597
|
+
var required = (name, defaultValue, kind) => ({ ...field(name, defaultValue, kind), required: true });
|
|
2598
|
+
var DEPLOY_MIDDLEWARE = {
|
|
2599
|
+
db: [
|
|
2600
|
+
{ backend: "sqlite", label: "SQLite", purpose: "deploy_purposeSqlite", fields: [required("database", "./data/app.db")] },
|
|
2601
|
+
...["postgresql", "mysql"].map((backend) => ({
|
|
2602
|
+
backend,
|
|
2603
|
+
label: backend === "mysql" ? "MySQL" : "PostgreSQL",
|
|
2604
|
+
purpose: "deploy_purposeDatabase",
|
|
2605
|
+
provisioner: backend === "postgresql" ? "neon" : void 0,
|
|
2606
|
+
fields: [field("url", "", "url", true), required("host", "localhost"), required("port", backend === "mysql" ? "3306" : "5432", "port"), required("database", "hai"), field("user", "hai"), field("password", "", void 0, true), field("ssl", "false")]
|
|
2607
|
+
}))
|
|
2608
|
+
],
|
|
2609
|
+
cache: [
|
|
2610
|
+
{ backend: "memory", label: "Memory", purpose: "deploy_purposeMemory", fields: [] },
|
|
2611
|
+
{ backend: "redis", label: "Redis", purpose: "deploy_purposeCache", provisioner: "upstash", fields: [field("url", "", "url", true), required("host", "localhost"), required("port", "6379", "port"), field("password", "", void 0, true), { ...field("db", "0", "number"), maximum: 15 }, field("tls", "false", "boolean"), field("keyPrefix"), field("connectTimeout", "10000", "number"), field("commandTimeout", "5000", "number")] }
|
|
2612
|
+
],
|
|
2613
|
+
storage: [
|
|
2614
|
+
{ backend: "local", label: "Local filesystem", purpose: "deploy_purposeLocal", fields: [required("root", "./data/storage")] },
|
|
2615
|
+
{ backend: "s3", label: "S3 / RustFS / MinIO / Cloudflare R2", purpose: "deploy_purposeStorage", provisioner: "cloudflare-r2", fields: [field("endpoint", "", "url"), required("bucket", "hai"), required("region", "us-east-1"), required("accessKeyId"), { ...required("secretAccessKey"), secret: true }, field("forcePathStyle", "true", "boolean")] }
|
|
2616
|
+
],
|
|
2617
|
+
vecdb: [
|
|
2618
|
+
{ backend: "lancedb", label: "LanceDB", purpose: "deploy_purposeLocal", fields: [required("path", "./data/vectors")] },
|
|
2619
|
+
{ backend: "qdrant", label: "Qdrant", purpose: "deploy_purposeVector", fields: [required("url", "http://localhost:6333", "url"), field("apiKey", "", void 0, true)] },
|
|
2620
|
+
{ backend: "pgvector", label: "PostgreSQL / pgvector", purpose: "deploy_purposeVector", fields: [field("url", "", "url", true), required("host", "localhost"), required("port", "5432", "port"), required("database", "hai"), field("user", "hai"), field("password", "", void 0, true)] },
|
|
2621
|
+
{ backend: "chroma", label: "Chroma", purpose: "deploy_purposeVector", fields: [required("url", "http://localhost:8000", "url")] }
|
|
2622
|
+
],
|
|
2623
|
+
ldap: [
|
|
2624
|
+
{ backend: "disabled", label: "Disabled", purpose: "deploy_purposeDisabled", fields: [] },
|
|
2625
|
+
{ backend: "ldap", label: "LDAP / OpenLDAP", purpose: "deploy_purposeLdap", fields: [required("url", "ldap://localhost:389", "url"), required("bindDn", "cn=admin,dc=example,dc=org"), { ...required("bindPassword"), secret: true }, required("searchBase", "dc=example,dc=org"), field("searchFilter", "(uid={{username}})"), field("usernameAttribute", "uid"), field("emailAttribute", "mail"), field("displayNameAttribute", "cn"), field("useTls", "false", "boolean"), { ...field("connectTimeout", "5000", "number"), minimum: 1e3 }] }
|
|
2626
|
+
],
|
|
2627
|
+
email: [
|
|
2628
|
+
{ backend: "console", label: "Console", purpose: "deploy_purposeConsole", fields: [] },
|
|
2629
|
+
{ backend: "smtp", label: "SMTP", purpose: "deploy_purposeEmail", provisioner: "resend", fields: [required("host", "localhost"), required("port", "465", "port"), field("secure", "true", "boolean"), field("user"), field("pass", "", void 0, true), required("from", "noreply@example.com")] },
|
|
2630
|
+
{ backend: "api", label: "HTTP notification API", purpose: "deploy_purposeEmail", fields: [required("url", "", "url"), field("method", "POST"), field("headers"), field("timeout", "10000", "number")] }
|
|
2631
|
+
],
|
|
2632
|
+
sms: [
|
|
2633
|
+
{ backend: "aliyun-sms", label: "Aliyun SMS", purpose: "deploy_purposeSms", provisioner: "aliyun", fields: [required("accessKeyId"), { ...required("accessKeySecret"), secret: true }, required("signName"), field("endpoint", "dysmsapi.aliyuncs.com")] }
|
|
2634
|
+
]
|
|
2635
|
+
};
|
|
2636
|
+
function describeMiddlewareBackend(backend) {
|
|
2637
|
+
return deployM(backend.purpose);
|
|
2246
2638
|
}
|
|
2247
2639
|
|
|
2248
|
-
export { DeployConfigSchema, HaiDeployError, ProviderConfigSchema, deploy, getDeployRecovery };
|
|
2640
|
+
export { DEPLOY_MIDDLEWARE, DeployConfigSchema, DeploymentArtifactSchema, HaiDeployError, ProviderConfigSchema, artifactChecksum, deploy, describeMiddlewareBackend, getDeployRecovery, loadDeploymentConfig, loadDeploymentEnvironment, readDeploymentArtifact, saveDeploymentEnvironment };
|
|
2249
2641
|
//# sourceMappingURL=index.js.map
|
|
2250
2642
|
//# sourceMappingURL=index.js.map
|