@h-ai/deploy 0.1.0-alpha.64 → 0.1.0-alpha.65
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 +134 -175
- package/README.ja.md +134 -175
- package/README.md +133 -175
- package/dist/index.d.ts +173 -6
- package/dist/index.js +631 -255
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { core,
|
|
3
|
-
import {
|
|
2
|
+
import { core, ok, err } from '@h-ai/core';
|
|
3
|
+
import { readFileSync, existsSync, writeFileSync, chmodSync, mkdirSync, statSync, readdirSync } from 'fs';
|
|
4
|
+
import { resolve, join, basename, dirname, relative, sep } from 'path';
|
|
5
|
+
import process2 from 'process';
|
|
6
|
+
import { parseEnv } from 'util';
|
|
7
|
+
import { parse, stringify, parseAllDocuments } from 'yaml';
|
|
4
8
|
import { homedir } from 'os';
|
|
5
|
-
import { join, dirname, relative, sep } from 'path';
|
|
6
|
-
import process from 'process';
|
|
7
|
-
import { parse, stringify } from 'yaml';
|
|
8
9
|
import { execSync, spawn } from 'child_process';
|
|
9
10
|
import { randomBytes, createHash } from 'crypto';
|
|
10
11
|
import { Buffer } from 'buffer';
|
|
@@ -46,7 +47,24 @@ var en_US_default = {
|
|
|
46
47
|
deploy_remoteConnectFailed: "Cannot connect to remote host {host}: {error}",
|
|
47
48
|
deploy_remoteRuntimeMissing: "Remote host missing container runtime or compose: {runtime}",
|
|
48
49
|
deploy_imageTransferFailed: "Image transfer failed: {error}",
|
|
49
|
-
deploy_healthCheckFailed: "Health check failed, app not ready at {url}"
|
|
50
|
+
deploy_healthCheckFailed: "Health check failed, app not ready at {url}",
|
|
51
|
+
deploy_invalidEnvironment: "Use letters, numbers, underscores or hyphens for the environment; local/example are reserved.",
|
|
52
|
+
deploy_environmentMissing: "Environment file missing: {path}. Create and configure it before deploying.",
|
|
53
|
+
deploy_environmentReadFailed: "Cannot read deployment environment files; check syntax and permissions.",
|
|
54
|
+
deploy_environmentWriteFailed: "Cannot save deployment configuration; check file syntax and permissions.",
|
|
55
|
+
deploy_multipleApplications: "Multiple service packages found; specify the application directory.",
|
|
56
|
+
deploy_purposeSqlite: "Local relational database persisted in a volume",
|
|
57
|
+
deploy_purposeDatabase: "Relational database service for business data",
|
|
58
|
+
deploy_purposeMemory: "Per-process memory cache; cleared on restart, no external service",
|
|
59
|
+
deploy_purposeLocal: "Local filesystem storage persisted in a volume",
|
|
60
|
+
deploy_purposeCache: "Shared cache, sessions and distributed locks",
|
|
61
|
+
deploy_purposeStorage: "S3-compatible object storage for uploaded files",
|
|
62
|
+
deploy_purposeVector: "Vector indexing and similarity search",
|
|
63
|
+
deploy_purposeDisabled: "LDAP login is disabled; no directory service is created",
|
|
64
|
+
deploy_purposeLdap: "Directory account authentication; the default directory has no business users",
|
|
65
|
+
deploy_purposeConsole: "Messages go to application logs without delivery",
|
|
66
|
+
deploy_purposeEmail: "Email/HTTP delivery; the default SMTP container only captures test messages",
|
|
67
|
+
deploy_purposeSms: "SMS through Aliyun credentials; requires an existing signature and quota"
|
|
50
68
|
};
|
|
51
69
|
|
|
52
70
|
// messages/zh-CN.json
|
|
@@ -84,7 +102,24 @@ var zh_CN_default = {
|
|
|
84
102
|
deploy_remoteConnectFailed: "\u65E0\u6CD5\u8FDE\u63A5\u8FDC\u7A0B\u4E3B\u673A {host}\uFF1A{error}",
|
|
85
103
|
deploy_remoteRuntimeMissing: "\u8FDC\u7A0B\u4E3B\u673A\u7F3A\u5C11\u5BB9\u5668\u8FD0\u884C\u65F6\u6216 compose\uFF1A{runtime}",
|
|
86
104
|
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"
|
|
105
|
+
deploy_healthCheckFailed: "\u5065\u5EB7\u68C0\u67E5\u5931\u8D25\uFF0C\u5E94\u7528\u672A\u5728 {url} \u5C31\u7EEA",
|
|
106
|
+
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",
|
|
107
|
+
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",
|
|
108
|
+
deploy_environmentReadFailed: "\u90E8\u7F72\u73AF\u5883\u6587\u4EF6\u8BFB\u53D6\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u6587\u4EF6\u683C\u5F0F\u4E0E\u6743\u9650\u3002",
|
|
109
|
+
deploy_environmentWriteFailed: "\u90E8\u7F72\u914D\u7F6E\u5199\u5165\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u6587\u4EF6\u683C\u5F0F\u4E0E\u6743\u9650\u3002",
|
|
110
|
+
deploy_multipleApplications: "\u53D1\u73B0\u591A\u4E2A\u670D\u52A1\u5305\uFF0C\u8BF7\u6307\u5B9A\u8981\u90E8\u7F72\u7684\u5E94\u7528\u76EE\u5F55\u3002",
|
|
111
|
+
deploy_purposeSqlite: "\u672C\u5730\u6587\u4EF6\u5173\u7CFB\u6570\u636E\u5E93\uFF0C\u4F7F\u7528\u6301\u4E45\u5316\u5377",
|
|
112
|
+
deploy_purposeDatabase: "\u72EC\u7ACB\u5173\u7CFB\u6570\u636E\u5E93\u670D\u52A1\uFF0C\u4FDD\u5B58\u4E1A\u52A1\u6570\u636E",
|
|
113
|
+
deploy_purposeMemory: "\u5355\u8FDB\u7A0B\u5185\u5B58\u7F13\u5B58\uFF0C\u91CD\u542F\u540E\u6E05\u7A7A\uFF0C\u4E0D\u521B\u5EFA\u5916\u90E8\u670D\u52A1",
|
|
114
|
+
deploy_purposeLocal: "\u672C\u5730\u6587\u4EF6\u5B58\u50A8\uFF0C\u4F7F\u7528\u6301\u4E45\u5316\u5377",
|
|
115
|
+
deploy_purposeCache: "\u5171\u4EAB\u7F13\u5B58\u3001\u4F1A\u8BDD\u4E0E\u5206\u5E03\u5F0F\u9501",
|
|
116
|
+
deploy_purposeStorage: "S3 \u517C\u5BB9\u5BF9\u8C61\u5B58\u50A8\uFF0C\u4FDD\u5B58\u4E0A\u4F20\u6587\u4EF6",
|
|
117
|
+
deploy_purposeVector: "\u5411\u91CF\u7D22\u5F15\u4E0E\u76F8\u4F3C\u5EA6\u641C\u7D22",
|
|
118
|
+
deploy_purposeDisabled: "LDAP \u767B\u5F55\u672A\u542F\u7528\uFF0C\u4E0D\u521B\u5EFA\u76EE\u5F55\u670D\u52A1",
|
|
119
|
+
deploy_purposeLdap: "\u4F01\u4E1A\u76EE\u5F55\u8D26\u53F7\u8BA4\u8BC1\uFF1B\u9ED8\u8BA4\u76EE\u5F55\u4E0D\u5305\u542B\u4E1A\u52A1\u7528\u6237",
|
|
120
|
+
deploy_purposeConsole: "\u6D88\u606F\u8F93\u51FA\u5230\u5E94\u7528\u65E5\u5FD7\uFF0C\u4E0D\u5B9E\u9645\u6295\u9012",
|
|
121
|
+
deploy_purposeEmail: "\u90AE\u4EF6\u6216 HTTP \u901A\u77E5\u6295\u9012\uFF1B\u9ED8\u8BA4 SMTP \u5BB9\u5668\u4EC5\u7528\u4E8E\u6D4B\u8BD5\u63A5\u6536",
|
|
122
|
+
deploy_purposeSms: "\u901A\u8FC7\u963F\u91CC\u4E91\u51ED\u8BC1\u63A5\u5165\u77ED\u4FE1\u670D\u52A1\uFF0C\u9700\u5DF2\u6709\u7B7E\u540D\u4E0E\u989D\u5EA6"
|
|
88
123
|
};
|
|
89
124
|
|
|
90
125
|
// src/deploy-i18n.ts
|
|
@@ -117,9 +152,9 @@ var SshConfigSchema = z.object({
|
|
|
117
152
|
var PortExposeSchema = z.object({
|
|
118
153
|
type: z.literal("port").default("port"),
|
|
119
154
|
/** 容器内监听端口(adapter-node 默认 3000) */
|
|
120
|
-
containerPort: z.number().int().
|
|
155
|
+
containerPort: z.number().int().min(1).max(65535).default(3e3),
|
|
121
156
|
/** 宿主机映射端口 */
|
|
122
|
-
hostPort: z.number().int().
|
|
157
|
+
hostPort: z.number().int().min(1).max(65535)
|
|
123
158
|
});
|
|
124
159
|
var ExposeSchema = PortExposeSchema;
|
|
125
160
|
var DockerSshProviderConfigSchema = z.object({
|
|
@@ -175,11 +210,22 @@ var ServicesConfigSchema = z.object({
|
|
|
175
210
|
sms: z.discriminatedUnion("provisioner", [AliyunSmsServiceSchema]).optional()
|
|
176
211
|
}).optional();
|
|
177
212
|
var DeployConfigSchema = z.object({
|
|
213
|
+
/** 部署环境文件模式;dev/test/prod 或自定义模式,禁止 local/example。 */
|
|
214
|
+
environment: z.string().regex(/^[a-z0-9][\w-]*$/i).refine((value) => !["local", "example"].includes(value.toLowerCase())).optional(),
|
|
178
215
|
provider: ProviderConfigSchema,
|
|
179
216
|
services: ServicesConfigSchema,
|
|
180
217
|
/** 本地容器构建参数(docker-ssh 使用;auto 优先 podman) */
|
|
181
218
|
container: z.object({
|
|
182
|
-
runtime: z.enum(["auto", "docker", "podman"]).default("auto")
|
|
219
|
+
runtime: z.enum(["auto", "docker", "podman"]).default("auto"),
|
|
220
|
+
/** Podman 构建是否继承代理;容器不能访问宿主机 localhost 代理时设为 false。 */
|
|
221
|
+
httpProxy: z.boolean().default(true),
|
|
222
|
+
/** 自建 S3 服务实现;协议仍为 storage.type=s3。 */
|
|
223
|
+
s3Engine: z.enum(["rustfs", "minio"]).default("rustfs"),
|
|
224
|
+
/** SQLite 预编译包镜像根地址;省略时从官方 GitHub Release 下载,不允许嵌入凭据。 */
|
|
225
|
+
sqlitePrebuildMirror: z.url().refine((value) => {
|
|
226
|
+
const url = new URL(value);
|
|
227
|
+
return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash;
|
|
228
|
+
}).optional()
|
|
183
229
|
}).optional()
|
|
184
230
|
});
|
|
185
231
|
var DeployErrorInfo = {
|
|
@@ -206,7 +252,165 @@ var DeployErrorInfo = {
|
|
|
206
252
|
};
|
|
207
253
|
var HaiDeployError = core.error.buildHaiErrorsDef("deploy", DeployErrorInfo);
|
|
208
254
|
|
|
209
|
-
// src/deploy-
|
|
255
|
+
// src/deploy-environment.ts
|
|
256
|
+
function resolveConfig(value, prefix, env) {
|
|
257
|
+
if (env[prefix] !== void 0)
|
|
258
|
+
return parse(env[prefix]) ?? env[prefix];
|
|
259
|
+
if (Array.isArray(value))
|
|
260
|
+
return value.map((item, index) => resolveConfig(item, `${prefix}_${index}`, env));
|
|
261
|
+
if (value && typeof value === "object")
|
|
262
|
+
return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, resolveConfig(nested, `${prefix}_${key.toUpperCase()}`, env)]));
|
|
263
|
+
if (typeof value !== "string")
|
|
264
|
+
return value;
|
|
265
|
+
const resolved = value.replace(/\$\{([^:}]+)(?::([^}]*))?\}/g, (_match, name, fallback) => env[name] ?? fallback ?? "");
|
|
266
|
+
return /^\$\{[^}]+\}$/.test(value) ? parse(resolved) ?? resolved : resolved;
|
|
267
|
+
}
|
|
268
|
+
function loadDeploymentConfig(name, path, values) {
|
|
269
|
+
try {
|
|
270
|
+
const config = resolveConfig(parse(readFileSync(path, "utf8")), `HAI_${name.toUpperCase()}`, values);
|
|
271
|
+
return ok(config && typeof config === "object" && !Array.isArray(config) ? config : {});
|
|
272
|
+
} catch {
|
|
273
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_environmentReadFailed"));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function loadDeploymentEnvironment(options) {
|
|
277
|
+
const environment = options.environment === "production" ? "prod" : options.environment === "development" ? "dev" : options.environment;
|
|
278
|
+
if (!/^[a-z0-9][\w-]*$/i.test(environment) || ["local", "example"].includes(environment.toLowerCase()))
|
|
279
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_invalidEnvironment"));
|
|
280
|
+
const envDir = resolve(options.envDir);
|
|
281
|
+
const target = join(envDir, `.env.${environment}`);
|
|
282
|
+
if (options.requireFile !== false && !existsSync(target))
|
|
283
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_environmentMissing", { params: { path: target } }));
|
|
284
|
+
const loaded = core.config.loadEnvFiles({ mode: environment, envDir });
|
|
285
|
+
if (!loaded.success)
|
|
286
|
+
return loaded;
|
|
287
|
+
try {
|
|
288
|
+
const values = { ...loaded.data };
|
|
289
|
+
for (const [name, value] of Object.entries(process2.env)) {
|
|
290
|
+
if (/^(?:HAI_|PUBLIC_|VITE_)/.test(name) && value !== void 0)
|
|
291
|
+
values[name] = value;
|
|
292
|
+
}
|
|
293
|
+
values.HAI_DEPLOY_ENVIRONMENT = environment;
|
|
294
|
+
values.HAI_CORE_ENV ??= environment === "prod" ? "production" : environment === "dev" ? "development" : environment === "test" ? "test" : "staging";
|
|
295
|
+
const targetValues = {};
|
|
296
|
+
if (existsSync(target)) {
|
|
297
|
+
for (const [name, value] of Object.entries(parseEnv(readFileSync(target, "utf8")))) {
|
|
298
|
+
if (value !== void 0)
|
|
299
|
+
targetValues[name] = value;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return ok({ environment, envDir, values, targetValues });
|
|
303
|
+
} catch {
|
|
304
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_environmentReadFailed"));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function isSecret(name, value) {
|
|
308
|
+
if (/PASSWORD|SECRET|TOKEN|APIKEY|PRIVATEKEY|ACCESSKEY|AUTHORIZATION/i.test(name.replace(/[^a-z0-9]/gi, "")) || /(?:^|_)PASS$/i.test(name) || value.includes("PRIVATE KEY"))
|
|
309
|
+
return true;
|
|
310
|
+
try {
|
|
311
|
+
const url = new URL(value);
|
|
312
|
+
if (url.username || url.password || [...url.searchParams.keys()].some((key) => /password|secret|token|key|authorization/i.test(key)))
|
|
313
|
+
return true;
|
|
314
|
+
} catch {
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
const parsed = JSON.parse(value);
|
|
318
|
+
if (parsed && typeof parsed === "object")
|
|
319
|
+
return Object.entries(parsed).some(([key, nested]) => isSecret(key, typeof nested === "string" ? nested : JSON.stringify(nested)));
|
|
320
|
+
} catch {
|
|
321
|
+
}
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
function publicConfig(value) {
|
|
325
|
+
if (Array.isArray(value))
|
|
326
|
+
return value.map(publicConfig);
|
|
327
|
+
if (value && typeof value === "object")
|
|
328
|
+
return Object.fromEntries(Object.entries(value).filter(([name, item]) => !isSecret(name, typeof item === "string" ? item : "")).map(([name, item]) => [name, publicConfig(item)]));
|
|
329
|
+
return value;
|
|
330
|
+
}
|
|
331
|
+
function quoteEnv(value) {
|
|
332
|
+
try {
|
|
333
|
+
const parsed = JSON.parse(value);
|
|
334
|
+
if (parsed && typeof parsed === "object")
|
|
335
|
+
return `'${JSON.stringify(parsed).replaceAll("'", "\\u0027")}'`;
|
|
336
|
+
} catch {
|
|
337
|
+
}
|
|
338
|
+
if (!value.includes("'"))
|
|
339
|
+
return `'${value}'`;
|
|
340
|
+
if (!value.includes('"'))
|
|
341
|
+
return `"${value}"`;
|
|
342
|
+
if (!value.includes("`"))
|
|
343
|
+
return `\`${value}\``;
|
|
344
|
+
if (!/[\r\n#]/.test(value))
|
|
345
|
+
return value;
|
|
346
|
+
throw new Error("Unsupported dotenv value");
|
|
347
|
+
}
|
|
348
|
+
function updateFile(path, values, privateFile) {
|
|
349
|
+
let content = existsSync(path) ? readFileSync(path, "utf8") : "# Local private deployment configuration; do not commit.\n";
|
|
350
|
+
for (const [name, value] of Object.entries(values)) {
|
|
351
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(name))
|
|
352
|
+
throw new Error("Invalid environment variable name");
|
|
353
|
+
const line = `${name}=${quoteEnv(value)}`;
|
|
354
|
+
const pattern = new RegExp(`^(?:export\\s+)?${name}\\s*=\\s*(?:'[^']*'|"[^"]*"|\`[^\`]*\`|[^\\r\\n]*)[^\\r\\n]*`, "gm");
|
|
355
|
+
if (pattern.test(content))
|
|
356
|
+
content = content.replace(pattern, () => line);
|
|
357
|
+
else
|
|
358
|
+
content = `${content.trimEnd()}
|
|
359
|
+
${line}
|
|
360
|
+
`;
|
|
361
|
+
}
|
|
362
|
+
writeFileSync(path, content, { mode: privateFile ? 384 : 420 });
|
|
363
|
+
if (privateFile)
|
|
364
|
+
chmodSync(path, 384);
|
|
365
|
+
}
|
|
366
|
+
function removeOverrides(path, names, privateFile = true) {
|
|
367
|
+
if (!existsSync(path) || names.length === 0)
|
|
368
|
+
return;
|
|
369
|
+
let content = readFileSync(path, "utf8");
|
|
370
|
+
for (const name of names)
|
|
371
|
+
content = content.replace(new RegExp(`^(?:export\\s+)?${name}\\s*=\\s*(?:'[^']*'|"[^"]*"|\`[^\`]*\`|[^\\r\\n]*)[^\\r\\n]*(?:\\r?\\n|$)`, "gm"), "");
|
|
372
|
+
writeFileSync(path, content, { mode: privateFile ? 384 : 420 });
|
|
373
|
+
chmodSync(path, privateFile ? 384 : 420);
|
|
374
|
+
}
|
|
375
|
+
function saveDeploymentEnvironment(environment, values, options = {}) {
|
|
376
|
+
try {
|
|
377
|
+
const shared = {};
|
|
378
|
+
const privateValues = {};
|
|
379
|
+
for (const [name, value] of Object.entries(values)) {
|
|
380
|
+
if (options.private) {
|
|
381
|
+
privateValues[name] = value;
|
|
382
|
+
} else if (isSecret(name, value)) {
|
|
383
|
+
privateValues[name] = value;
|
|
384
|
+
try {
|
|
385
|
+
const parsed = JSON.parse(value);
|
|
386
|
+
if (parsed && typeof parsed === "object")
|
|
387
|
+
shared[name] = JSON.stringify(publicConfig(parsed));
|
|
388
|
+
} catch {
|
|
389
|
+
}
|
|
390
|
+
} else {
|
|
391
|
+
shared[name] = value;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (Object.keys(shared).length)
|
|
395
|
+
updateFile(join(environment.envDir, `.env.${environment.environment}`), shared, false);
|
|
396
|
+
if (Object.keys(privateValues).length)
|
|
397
|
+
updateFile(join(environment.envDir, ".env.local"), privateValues, true);
|
|
398
|
+
removeOverrides(join(environment.envDir, `.env.${environment.environment}`), Object.keys(privateValues).filter((name) => !(name in shared)), false);
|
|
399
|
+
removeOverrides(join(environment.envDir, ".env.local"), Object.keys(shared).filter((name) => !(name in privateValues)));
|
|
400
|
+
if (Object.keys(privateValues).length) {
|
|
401
|
+
const ignorePath = join(environment.envDir, ".gitignore");
|
|
402
|
+
const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : "";
|
|
403
|
+
if (!ignore.split(/\r?\n/).includes(".env.local"))
|
|
404
|
+
writeFileSync(ignorePath, `${ignore.trimEnd()}
|
|
405
|
+
.env.local
|
|
406
|
+
.hai/deploy/
|
|
407
|
+
`);
|
|
408
|
+
}
|
|
409
|
+
return ok(Object.keys(values));
|
|
410
|
+
} catch {
|
|
411
|
+
return err(HaiDeployError.ENV_VAR_FAILED, deployM("deploy_environmentWriteFailed"));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
210
414
|
var logger = core.logger.child({ module: "deploy", scope: "credentials" });
|
|
211
415
|
var DEPLOY_CREDENTIAL_KEY_PATTERN = /^HAI_DEPLOY_[A-Z0-9_]+$/;
|
|
212
416
|
function isAllowedCredentialKey(key) {
|
|
@@ -270,7 +474,7 @@ function loadCredentials() {
|
|
|
270
474
|
const data = readCredentialsFile();
|
|
271
475
|
const keys = Object.keys(data);
|
|
272
476
|
for (const key of keys) {
|
|
273
|
-
|
|
477
|
+
process2.env[key] = String(data[key]);
|
|
274
478
|
}
|
|
275
479
|
logger.debug("Credentials loaded", { count: keys.length });
|
|
276
480
|
return ok(keys);
|
|
@@ -291,7 +495,7 @@ function saveCredential(key, value) {
|
|
|
291
495
|
const data = readCredentialsFile();
|
|
292
496
|
data[key] = value;
|
|
293
497
|
writeCredentialsFile(data);
|
|
294
|
-
|
|
498
|
+
process2.env[key] = value;
|
|
295
499
|
logger.debug("Credential saved", { key });
|
|
296
500
|
return ok(void 0);
|
|
297
501
|
} catch (error) {
|
|
@@ -311,7 +515,7 @@ function saveCredentials(entries) {
|
|
|
311
515
|
const data = readCredentialsFile();
|
|
312
516
|
for (const [key, value] of Object.entries(entries)) {
|
|
313
517
|
data[key] = value;
|
|
314
|
-
|
|
518
|
+
process2.env[key] = value;
|
|
315
519
|
}
|
|
316
520
|
writeCredentialsFile(data);
|
|
317
521
|
logger.debug("Credentials saved", { count: Object.keys(entries).length });
|
|
@@ -328,13 +532,17 @@ function saveCredentials(entries) {
|
|
|
328
532
|
}
|
|
329
533
|
}
|
|
330
534
|
var logger2 = core.logger.child({ module: "deploy", scope: "functions" });
|
|
535
|
+
function usesDefaultMiddleware(scan, service, options) {
|
|
536
|
+
const requirements = scan.middlewares?.filter((item) => item.service === service) ?? [];
|
|
537
|
+
return requirements.length ? requirements.some((item) => (options?.serviceModes?.[item.envPrefix] ?? options?.serviceModes?.[service] ?? "default") === "default") : (options?.serviceModes?.[service] ?? "default") === "default";
|
|
538
|
+
}
|
|
331
539
|
function buildApp(appDir, buildCommand, envVars = {}) {
|
|
332
540
|
logger2.info("Building application", { appDir, buildCommand });
|
|
333
541
|
try {
|
|
334
542
|
execSync(buildCommand, {
|
|
335
543
|
cwd: appDir,
|
|
336
544
|
stdio: "inherit",
|
|
337
|
-
env: { ...
|
|
545
|
+
env: { ...process2.env, ...envVars }
|
|
338
546
|
});
|
|
339
547
|
return ok(void 0);
|
|
340
548
|
} catch (error) {
|
|
@@ -387,195 +595,144 @@ function recoveryFailure(error, recovery) {
|
|
|
387
595
|
});
|
|
388
596
|
}
|
|
389
597
|
var logger3 = core.logger.child({ module: "deploy", scope: "scanner" });
|
|
390
|
-
function
|
|
391
|
-
|
|
392
|
-
|
|
598
|
+
function record(value) {
|
|
599
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
600
|
+
}
|
|
601
|
+
function runtimeDependencies(appDir) {
|
|
602
|
+
let root = appDir;
|
|
603
|
+
while (!existsSync(join(root, "pnpm-lock.yaml"))) {
|
|
604
|
+
const parent = dirname(root);
|
|
605
|
+
if (parent === root)
|
|
606
|
+
throw new Error("Missing pnpm lockfile; run pnpm install");
|
|
607
|
+
root = parent;
|
|
393
608
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
609
|
+
const importerKey = relative(root, appDir).replaceAll("\\", "/") || ".";
|
|
610
|
+
const documents = parseAllDocuments(readFileSync(join(root, "pnpm-lock.yaml"), "utf8"));
|
|
611
|
+
if (documents.some((document) => document.errors.length))
|
|
612
|
+
throw new Error("Invalid pnpm lockfile");
|
|
613
|
+
const lock = documents.map((document) => record(document.toJS())).reverse().find((document) => importerKey in record(document.importers));
|
|
614
|
+
if (!lock)
|
|
615
|
+
throw new Error("Application is missing from pnpm lockfile; run pnpm install");
|
|
616
|
+
const importers = record(lock.importers);
|
|
617
|
+
const packages = record(lock.packages);
|
|
618
|
+
const snapshots = record(lock.snapshots);
|
|
619
|
+
const names = /* @__PURE__ */ new Set();
|
|
620
|
+
const visited = /* @__PURE__ */ new Set();
|
|
621
|
+
const visit = (id, node, importer) => {
|
|
622
|
+
if (visited.has(id))
|
|
623
|
+
return;
|
|
624
|
+
visited.add(id);
|
|
625
|
+
const metadata = importer === void 0 ? record(packages[id.split("(")[0]]) : {};
|
|
626
|
+
const peers = record(metadata.peerDependencies);
|
|
627
|
+
for (const [name, reference] of Object.entries({ ...record(node.dependencies), ...record(node.optionalDependencies) })) {
|
|
628
|
+
if (name in peers)
|
|
629
|
+
continue;
|
|
630
|
+
const version = typeof reference === "string" ? reference : record(reference).version;
|
|
631
|
+
if (typeof version !== "string")
|
|
632
|
+
throw new Error("Invalid pnpm dependency reference");
|
|
633
|
+
if (version.startsWith("link:")) {
|
|
634
|
+
if (importer === void 0)
|
|
635
|
+
throw new Error("Invalid workspace dependency");
|
|
636
|
+
const target = relative(root, resolve(root, importer, version.slice(5))).replaceAll("\\", "/") || ".";
|
|
637
|
+
const linked = record(importers[target]);
|
|
638
|
+
if (!Object.keys(linked).length)
|
|
639
|
+
throw new Error("Missing linked workspace importer");
|
|
640
|
+
names.add(name);
|
|
641
|
+
visit(`workspace:${target}`, linked, target);
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
const key = version in snapshots || version in packages ? version : `${name}@${version}`;
|
|
645
|
+
names.add(key.match(/^(@[^/]+\/[^@]+|[^@]+)@/)?.[1] ?? name);
|
|
646
|
+
const snapshot = record(snapshots[key] ?? packages[key]);
|
|
647
|
+
if (!Object.keys(snapshot).length && !(key in snapshots) && !(key in packages))
|
|
648
|
+
throw new Error("Missing pnpm dependency snapshot");
|
|
649
|
+
visit(key, snapshot);
|
|
399
650
|
}
|
|
400
|
-
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
}
|
|
651
|
+
};
|
|
652
|
+
visit(`workspace:${importerKey}`, record(importers[importerKey]), importerKey);
|
|
653
|
+
return { runtime: names, build: /* @__PURE__ */ new Set([...names, ...Object.keys(record(record(importers[importerKey]).devDependencies))]), root };
|
|
404
654
|
}
|
|
405
|
-
function
|
|
406
|
-
if (!existsSync(filePath)) {
|
|
407
|
-
return null;
|
|
408
|
-
}
|
|
655
|
+
async function scanApp(inputDir, options) {
|
|
409
656
|
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";
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
const vecdbFile = files.find((f) => f.startsWith("_vecdb."));
|
|
453
|
-
if (vecdbFile) {
|
|
454
|
-
const vecdbConfig = readYaml(join(configDir, vecdbFile));
|
|
455
|
-
if (vecdbConfig !== null) {
|
|
456
|
-
services.push("vecdb");
|
|
457
|
-
backends.vecdb = vecdbConfig.type ?? "qdrant";
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
const reachFile = files.find((f) => f.startsWith("_reach."));
|
|
461
|
-
if (reachFile) {
|
|
462
|
-
const reachConfig = readYaml(join(configDir, reachFile));
|
|
463
|
-
if (reachConfig !== null) {
|
|
464
|
-
detectReachServices(reachConfig, services);
|
|
465
|
-
}
|
|
466
|
-
}
|
|
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
|
-
}
|
|
479
|
-
function detectReachServices(config, services) {
|
|
480
|
-
const providers = config.providers;
|
|
481
|
-
if (!Array.isArray(providers)) {
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
484
|
-
let hasEmail = false;
|
|
485
|
-
let hasSms = false;
|
|
486
|
-
for (const provider of providers) {
|
|
487
|
-
const providerType = provider.type;
|
|
488
|
-
if (!hasEmail && (providerType === "smtp" || providerType === "api")) {
|
|
489
|
-
hasEmail = true;
|
|
490
|
-
services.push("email");
|
|
657
|
+
const appDir = resolve(inputDir);
|
|
658
|
+
const graph = runtimeDependencies(appDir);
|
|
659
|
+
const envDir = options?.envDir ? resolve(options.envDir) : existsSync(join(appDir, ".env")) ? appDir : graph.root;
|
|
660
|
+
const loaded = loadDeploymentEnvironment({ environment: options?.environment ?? "prod", envDir, requireFile: options?.environment !== void 0 });
|
|
661
|
+
if (!loaded.success)
|
|
662
|
+
return loaded;
|
|
663
|
+
const env = loaded.data.values;
|
|
664
|
+
const deps = graph.runtime;
|
|
665
|
+
const configs = {};
|
|
666
|
+
for (const [name, owner] of Object.entries({ db: "reldb", cache: "cache", storage: "storage", vecdb: "vecdb", iam: "iam", reach: "reach" })) {
|
|
667
|
+
configs[name] = {};
|
|
668
|
+
if (!deps.has(`@h-ai/${owner}`))
|
|
669
|
+
continue;
|
|
670
|
+
const configFile = ["yml", "yaml"].map((ext) => join(appDir, "config", `_${name}.${ext}`)).find(existsSync);
|
|
671
|
+
const config = configFile ? loadDeploymentConfig(name, configFile, env) : void 0;
|
|
672
|
+
if (config && !config.success)
|
|
673
|
+
return config;
|
|
674
|
+
configs[name] = config?.data ?? {};
|
|
491
675
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
676
|
+
const middlewares = [];
|
|
677
|
+
const add = (service, backend, configName = service, configPath = [], config = configs[configName] ?? {}, source = `_${configName}.yml`) => {
|
|
678
|
+
middlewares.push({ service, backend, source, configName, configPath, envPrefix: ["HAI", configName, ...configPath].map((part) => part.toUpperCase()).join("_"), config });
|
|
679
|
+
};
|
|
680
|
+
const iam = deps.has("@h-ai/iam");
|
|
681
|
+
if (deps.has("@h-ai/reldb"))
|
|
682
|
+
add("db", String(configs.db.type ?? "sqlite"), "db", [], configs.db, "@h-ai/reldb / _db.yml");
|
|
683
|
+
if (deps.has("@h-ai/cache"))
|
|
684
|
+
add("cache", String(configs.cache.type ?? "memory"), "cache", [], configs.cache, "@h-ai/cache / _cache.yml");
|
|
685
|
+
if (deps.has("@h-ai/storage"))
|
|
686
|
+
add("storage", String(configs.storage.type ?? "local"));
|
|
687
|
+
if (deps.has("@h-ai/vecdb"))
|
|
688
|
+
add("vecdb", String(configs.vecdb.type ?? "lancedb"));
|
|
689
|
+
if (iam) {
|
|
690
|
+
const ldap = record(configs.iam.ldap);
|
|
691
|
+
add("ldap", record(configs.iam.login).ldap === true ? "ldap" : "disabled", "iam", ["ldap"], ldap, "@h-ai/iam (LDAP)");
|
|
495
692
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
deployM("deploy_scanFailed", { params: { error: "package.json not found" } })
|
|
506
|
-
);
|
|
693
|
+
const providers = configs.reach.providers;
|
|
694
|
+
if (deps.has("@h-ai/reach") && Array.isArray(providers)) {
|
|
695
|
+
for (const [index, item] of providers.entries()) {
|
|
696
|
+
const provider = record(item);
|
|
697
|
+
const type = String(provider.type ?? "");
|
|
698
|
+
const service = type === "aliyun-sms" ? "sms" : ["smtp", "api", "console"].includes(type) ? "email" : void 0;
|
|
699
|
+
if (service)
|
|
700
|
+
add(service, type, "reach", ["providers", String(index)], provider, "@h-ai/reach");
|
|
701
|
+
}
|
|
507
702
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
const
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
const buildCommand = scripts?.build ?? "pnpm build";
|
|
518
|
-
const scanResult = {
|
|
519
|
-
appName,
|
|
703
|
+
if (deps.has("@h-ai/reach") && !middlewares.some((item) => item.configName === "reach"))
|
|
704
|
+
add("email", "console", "reach", ["providers", "0"], { name: "email", type: "console" }, "@h-ai/reach");
|
|
705
|
+
const isSvelteKit = graph.build.has("@sveltejs/kit");
|
|
706
|
+
const scan = {
|
|
707
|
+
appDir,
|
|
708
|
+
envDir,
|
|
709
|
+
middlewares,
|
|
710
|
+
haiModules: [...deps].filter((name) => name.startsWith("@h-ai/")).sort(),
|
|
711
|
+
appName: basename(appDir),
|
|
520
712
|
isSvelteKit,
|
|
521
|
-
adapterInstalled,
|
|
522
|
-
nodeAdapterInstalled,
|
|
523
|
-
requiredServices,
|
|
524
|
-
serviceBackends:
|
|
525
|
-
buildCommand
|
|
713
|
+
adapterInstalled: graph.build.has("@sveltejs/adapter-vercel"),
|
|
714
|
+
nodeAdapterInstalled: graph.build.has("@sveltejs/adapter-node"),
|
|
715
|
+
requiredServices: [...new Set(middlewares.map((item) => item.service))],
|
|
716
|
+
serviceBackends: Object.fromEntries(middlewares.map((item) => [item.service, item.backend])),
|
|
717
|
+
buildCommand: "pnpm build"
|
|
526
718
|
};
|
|
527
|
-
logger3.info("Application scanned", {
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
nodeAdapterInstalled,
|
|
532
|
-
requiredServices,
|
|
533
|
-
serviceBackends: configDetection.backends
|
|
534
|
-
});
|
|
535
|
-
return ok(scanResult);
|
|
536
|
-
} catch (error) {
|
|
537
|
-
logger3.error("Application scan failed", { appDir, error });
|
|
538
|
-
return err(
|
|
539
|
-
HaiDeployError.SCAN_FAILED,
|
|
540
|
-
deployM("deploy_scanFailed", {
|
|
541
|
-
params: { error: error instanceof Error ? error.message : String(error) }
|
|
542
|
-
}),
|
|
543
|
-
error
|
|
544
|
-
);
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
function detectServicesFromDependencies(pkg) {
|
|
548
|
-
const depMap = {
|
|
549
|
-
"@h-ai/reldb": "db",
|
|
550
|
-
"@h-ai/cache": "cache",
|
|
551
|
-
"@h-ai/storage": "storage",
|
|
552
|
-
"@h-ai/vecdb": "vecdb"
|
|
553
|
-
};
|
|
554
|
-
const services = [];
|
|
555
|
-
for (const [depName, serviceType] of Object.entries(depMap)) {
|
|
556
|
-
if (hasDependency(pkg, depName)) {
|
|
557
|
-
services.push(serviceType);
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
return services;
|
|
561
|
-
}
|
|
562
|
-
function extractAppName(pkg) {
|
|
563
|
-
const name = pkg.name ?? "app";
|
|
564
|
-
const slashIndex = name.indexOf("/");
|
|
565
|
-
if (slashIndex >= 0) {
|
|
566
|
-
return name.slice(slashIndex + 1);
|
|
719
|
+
logger3.info("Application scanned", { appName: scan.appName, haiModules: scan.haiModules, requiredServices: scan.requiredServices, serviceBackends: scan.serviceBackends });
|
|
720
|
+
return ok(scan);
|
|
721
|
+
} catch {
|
|
722
|
+
return err(HaiDeployError.SCAN_FAILED, deployM("deploy_scanFailed", { params: { error: "Invalid or ambiguous application/configuration/lockfile; specify the application directory and run pnpm install" } }));
|
|
567
723
|
}
|
|
568
|
-
return name;
|
|
569
724
|
}
|
|
570
725
|
var IMAGES = {
|
|
571
726
|
postgres: "postgres:16-alpine",
|
|
572
727
|
mysql: "mysql:8.4",
|
|
573
728
|
redis: "redis:7-alpine",
|
|
574
|
-
|
|
575
|
-
|
|
729
|
+
rustfs: "ghcr.io/rustfs/rustfs:1.0.1",
|
|
730
|
+
minio: "ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z",
|
|
576
731
|
qdrant: "qdrant/qdrant:v1.12.4",
|
|
577
732
|
pgvector: "pgvector/pgvector:pg17",
|
|
578
|
-
chroma: "chromadb/chroma:
|
|
733
|
+
chroma: "chromadb/chroma:1.5.5",
|
|
734
|
+
ldap: "docker.io/osixia/openldap:1.5.0",
|
|
735
|
+
smtp: "axllent/mailpit:v1.27.4"
|
|
579
736
|
};
|
|
580
737
|
function generatePassword() {
|
|
581
738
|
return randomBytes(24).toString("base64url");
|
|
@@ -590,7 +747,8 @@ function resolveSidecarSecrets(appDir, projectName) {
|
|
|
590
747
|
}
|
|
591
748
|
const secrets = {
|
|
592
749
|
dbPassword: generatePassword(),
|
|
593
|
-
storagePassword: generatePassword()
|
|
750
|
+
storagePassword: generatePassword(),
|
|
751
|
+
ldapPassword: generatePassword()
|
|
594
752
|
};
|
|
595
753
|
mkdirSync(dirname(secretFile), { recursive: true });
|
|
596
754
|
writeFileSync(secretFile, JSON.stringify(secrets), { mode: 384 });
|
|
@@ -651,38 +809,57 @@ function planRedisCache(plan, projectName, net) {
|
|
|
651
809
|
};
|
|
652
810
|
plan.env.HAI_CACHE = JSON.stringify({ type: "redis", url: "redis://cache:6379" });
|
|
653
811
|
}
|
|
654
|
-
function
|
|
812
|
+
function planS3Storage(plan, projectName, password, net, engine) {
|
|
655
813
|
plan.services.push("storage");
|
|
656
814
|
plan.dependsOn.push("storage");
|
|
657
815
|
plan.composeServices.storage = {
|
|
658
|
-
image: IMAGES
|
|
816
|
+
image: IMAGES[engine],
|
|
659
817
|
container_name: `hai-${projectName}-storage`,
|
|
660
818
|
restart: "unless-stopped",
|
|
661
|
-
command: ["server", "/data"],
|
|
819
|
+
...engine === "minio" ? { command: ["server", "/data"] } : {},
|
|
662
820
|
env_file: [".env.runtime"],
|
|
663
821
|
volumes: [`${projectName}-storage-data:/data`],
|
|
664
822
|
networks: net
|
|
665
823
|
};
|
|
666
824
|
plan.composeServices["storage-init"] = {
|
|
667
|
-
image: IMAGES.mc,
|
|
668
825
|
depends_on: ["storage"],
|
|
669
826
|
env_file: [".env.runtime"],
|
|
670
827
|
entrypoint: [
|
|
671
|
-
"
|
|
672
|
-
"-
|
|
673
|
-
|
|
828
|
+
"node",
|
|
829
|
+
"--input-type=module",
|
|
830
|
+
"-e",
|
|
831
|
+
// 复用应用 @h-ai/storage 已依赖的 SDK;不额外拉取失效的 mc 镜像,不把密钥拼进命令。
|
|
832
|
+
`import { S3Client, HeadBucketCommand, CreateBucketCommand } from '@aws-sdk/client-s3';
|
|
833
|
+
const config = JSON.parse(process.env.HAI_STORAGE);
|
|
834
|
+
const client = new S3Client({ endpoint: config.endpoint, region: config.region, forcePathStyle: config.forcePathStyle, credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey } });
|
|
835
|
+
let ready = false;
|
|
836
|
+
for (let attempt = 0; attempt < 120; attempt++) {
|
|
837
|
+
try {
|
|
838
|
+
try { await client.send(new HeadBucketCommand({ Bucket: config.bucket })); }
|
|
839
|
+
catch (error) {
|
|
840
|
+
if (error.$$metadata?.httpStatusCode !== 404) throw error;
|
|
841
|
+
await client.send(new CreateBucketCommand({ Bucket: config.bucket }));
|
|
842
|
+
}
|
|
843
|
+
ready = true; break;
|
|
844
|
+
} catch { await new Promise(resolve => setTimeout(resolve, 1000)); }
|
|
845
|
+
}
|
|
846
|
+
client.destroy();
|
|
847
|
+
if (!ready) throw new Error('S3 bucket initialization failed');`
|
|
674
848
|
],
|
|
675
849
|
networks: net
|
|
676
850
|
};
|
|
677
851
|
plan.volumes[`${projectName}-storage-data`] = null;
|
|
678
|
-
|
|
679
|
-
|
|
852
|
+
if (engine === "rustfs") {
|
|
853
|
+
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" });
|
|
854
|
+
} else {
|
|
855
|
+
Object.assign(plan.env, { MINIO_ROOT_USER: "hai-storage", MINIO_ROOT_PASSWORD: password });
|
|
856
|
+
}
|
|
680
857
|
plan.env.HAI_STORAGE = JSON.stringify({
|
|
681
858
|
type: "s3",
|
|
682
859
|
endpoint: "http://storage:9000",
|
|
683
860
|
bucket: "hai",
|
|
684
861
|
region: "us-east-1",
|
|
685
|
-
accessKeyId: "hai",
|
|
862
|
+
accessKeyId: "hai-storage",
|
|
686
863
|
secretAccessKey: password,
|
|
687
864
|
forcePathStyle: true
|
|
688
865
|
});
|
|
@@ -732,7 +909,7 @@ function planChromaVecdb(plan, projectName, net) {
|
|
|
732
909
|
plan.volumes[`${projectName}-vecdb-data`] = null;
|
|
733
910
|
plan.env.HAI_VECDB = JSON.stringify({ type: "chroma", url: "http://vecdb:8000" });
|
|
734
911
|
}
|
|
735
|
-
function planSidecars(appDir, projectName, scan, provisionEnv) {
|
|
912
|
+
function planSidecars(appDir, projectName, scan, provisionEnv, options, s3Engine = "rustfs") {
|
|
736
913
|
const plan = {
|
|
737
914
|
services: [],
|
|
738
915
|
composeServices: {},
|
|
@@ -742,10 +919,14 @@ function planSidecars(appDir, projectName, scan, provisionEnv) {
|
|
|
742
919
|
dependsOn: [],
|
|
743
920
|
secrets: []
|
|
744
921
|
};
|
|
922
|
+
if (!scan.requiredServices.some((service) => usesDefaultMiddleware(scan, service, options)))
|
|
923
|
+
return plan;
|
|
745
924
|
const secrets = resolveSidecarSecrets(appDir, projectName);
|
|
746
925
|
const net = ["hai-app"];
|
|
747
926
|
const backends = scan.serviceBackends;
|
|
748
927
|
for (const service of scan.requiredServices) {
|
|
928
|
+
if (!usesDefaultMiddleware(scan, service, options))
|
|
929
|
+
continue;
|
|
749
930
|
if (service === "db" && !provisionEnv.HAI_DB) {
|
|
750
931
|
const backend = backends.db ?? "postgresql";
|
|
751
932
|
if (backend === "mysql")
|
|
@@ -755,9 +936,13 @@ function planSidecars(appDir, projectName, scan, provisionEnv) {
|
|
|
755
936
|
else
|
|
756
937
|
planPostgresDb(plan, projectName, secrets.dbPassword, net);
|
|
757
938
|
} else if (service === "cache" && !provisionEnv.HAI_CACHE) {
|
|
758
|
-
|
|
939
|
+
if ((backends.cache ?? "redis") === "redis")
|
|
940
|
+
planRedisCache(plan, projectName, net);
|
|
759
941
|
} else if (service === "storage" && !provisionEnv.HAI_STORAGE) {
|
|
760
|
-
|
|
942
|
+
if ((backends.storage ?? "s3") === "s3")
|
|
943
|
+
planS3Storage(plan, projectName, secrets.storagePassword, net, s3Engine);
|
|
944
|
+
else
|
|
945
|
+
addAppDataVolume(plan, projectName);
|
|
761
946
|
} else if (service === "vecdb" && !provisionEnv.HAI_VECDB) {
|
|
762
947
|
const backend = backends.vecdb ?? "qdrant";
|
|
763
948
|
if (backend === "pgvector")
|
|
@@ -768,16 +953,39 @@ function planSidecars(appDir, projectName, scan, provisionEnv) {
|
|
|
768
953
|
addAppDataVolume(plan, projectName);
|
|
769
954
|
else
|
|
770
955
|
planQdrantVecdb(plan, projectName, net);
|
|
956
|
+
} else if (service === "ldap" && backends.ldap === "ldap" && !provisionEnv.HAI_IAM_LDAP) {
|
|
957
|
+
const password = secrets.ldapPassword ?? secrets.dbPassword;
|
|
958
|
+
plan.services.push("ldap");
|
|
959
|
+
plan.dependsOn.push("ldap");
|
|
960
|
+
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`] };
|
|
961
|
+
plan.volumes[`${projectName}-ldap-data`] = null;
|
|
962
|
+
plan.volumes[`${projectName}-ldap-config`] = null;
|
|
963
|
+
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" }) });
|
|
964
|
+
plan.secrets.push(password);
|
|
965
|
+
} 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) {
|
|
966
|
+
plan.services.push("email");
|
|
967
|
+
plan.dependsOn.push("email");
|
|
968
|
+
plan.composeServices.email = { image: IMAGES.smtp, restart: "unless-stopped", networks: net };
|
|
969
|
+
const requirements = scan.middlewares?.filter((item) => item.service === "email" && item.backend === "smtp" && ["default", void 0].includes(options?.serviceModes?.[item.envPrefix] ?? options?.serviceModes?.email));
|
|
970
|
+
if (requirements?.length) {
|
|
971
|
+
for (const requirement of requirements)
|
|
972
|
+
plan.env[requirement.envPrefix] = JSON.stringify({ name: requirement.config.name ?? "email", type: "smtp", host: "email", port: 1025, secure: false, from: "noreply@example.com" });
|
|
973
|
+
} else if (!scan.middlewares) {
|
|
974
|
+
plan.env.HAI_REACH_PROVIDERS_0 = JSON.stringify({ name: "email", type: "smtp", host: "email", port: 1025, secure: false, from: "noreply@example.com" });
|
|
975
|
+
}
|
|
771
976
|
}
|
|
772
977
|
}
|
|
773
978
|
return plan;
|
|
774
979
|
}
|
|
775
|
-
function buildRuntimeEnv(appDir, projectName, scan, provisionEnv, containerPort) {
|
|
776
|
-
const sidecar = planSidecars(appDir, projectName, scan, provisionEnv);
|
|
980
|
+
function buildRuntimeEnv(appDir, projectName, scan, provisionEnv, containerPort, options, appEnv = {}, s3Engine = "rustfs") {
|
|
981
|
+
const sidecar = planSidecars(appDir, projectName, scan, provisionEnv, options, s3Engine);
|
|
777
982
|
const env = {
|
|
778
983
|
NODE_ENV: "production",
|
|
779
984
|
HOST: "0.0.0.0",
|
|
780
985
|
PORT: String(containerPort),
|
|
986
|
+
HAI_SERV_SERVER_HOST: "0.0.0.0",
|
|
987
|
+
HAI_SERV_SERVER_PORT: String(containerPort),
|
|
988
|
+
...appEnv,
|
|
781
989
|
...sidecar.env,
|
|
782
990
|
...provisionEnv
|
|
783
991
|
};
|
|
@@ -799,21 +1007,55 @@ function generateComposeFile(options) {
|
|
|
799
1007
|
app.volumes = sidecar.appVolumes;
|
|
800
1008
|
if (sidecar.dependsOn.length > 0)
|
|
801
1009
|
app.depends_on = sidecar.dependsOn;
|
|
1010
|
+
const services = { app, ...sidecar.composeServices };
|
|
1011
|
+
if (services["storage-init"])
|
|
1012
|
+
services["storage-init"] = { ...services["storage-init"], image: imageRef };
|
|
802
1013
|
const compose = {
|
|
803
|
-
services
|
|
804
|
-
app,
|
|
805
|
-
...sidecar.composeServices
|
|
806
|
-
},
|
|
1014
|
+
services,
|
|
807
1015
|
networks: {
|
|
808
1016
|
"hai-app": { name: `hai-${projectName}` }
|
|
809
1017
|
}
|
|
810
1018
|
};
|
|
811
1019
|
if (Object.keys(sidecar.volumes).length > 0)
|
|
812
1020
|
compose.volumes = sidecar.volumes;
|
|
1021
|
+
if (options.previousCompose && options.serviceModes) {
|
|
1022
|
+
const previous = parse(options.previousCompose);
|
|
1023
|
+
for (const [service, mode] of Object.entries(options.serviceModes)) {
|
|
1024
|
+
if (mode !== "keep")
|
|
1025
|
+
continue;
|
|
1026
|
+
for (const name of [service, `${service}-init`]) {
|
|
1027
|
+
if (previous.services?.[name]) {
|
|
1028
|
+
services[name] = previous.services[name];
|
|
1029
|
+
app.depends_on = [.../* @__PURE__ */ new Set([...app.depends_on ?? [], service])];
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
const previousMounts = previous.services?.app?.volumes;
|
|
1034
|
+
if (Array.isArray(previousMounts) && Object.values(options.serviceModes).includes("keep"))
|
|
1035
|
+
app.volumes = [.../* @__PURE__ */ new Set([...app.volumes ?? [], ...previousMounts])];
|
|
1036
|
+
compose.volumes = { ...previous.volumes, ...sidecar.volumes };
|
|
1037
|
+
}
|
|
1038
|
+
if (services["storage-init"]) {
|
|
1039
|
+
app.depends_on = {
|
|
1040
|
+
...Object.fromEntries((app.depends_on ?? []).map((name) => [name, { condition: "service_started" }])),
|
|
1041
|
+
"storage-init": { condition: "service_completed_successfully" }
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
813
1044
|
return stringify(compose);
|
|
814
1045
|
}
|
|
815
1046
|
function serializeEnvFile(env) {
|
|
816
|
-
return `${Object.entries(env).map(([key, value]) =>
|
|
1047
|
+
return `${Object.entries(env).map(([key, value]) => {
|
|
1048
|
+
let literal = value;
|
|
1049
|
+
if (/[\s#$'"]/.test(value)) {
|
|
1050
|
+
if (value.startsWith("{") || value.startsWith("["))
|
|
1051
|
+
literal = `'${value.replaceAll("'", "\\u0027")}'`;
|
|
1052
|
+
else if (!value.includes("'"))
|
|
1053
|
+
literal = `'${value}'`;
|
|
1054
|
+
else
|
|
1055
|
+
literal = JSON.stringify(value).replaceAll("$", "$$");
|
|
1056
|
+
}
|
|
1057
|
+
return `${key}=${literal}`;
|
|
1058
|
+
}).join("\n")}
|
|
817
1059
|
`;
|
|
818
1060
|
}
|
|
819
1061
|
function generateDockerignore() {
|
|
@@ -826,31 +1068,46 @@ function generateDockerignore() {
|
|
|
826
1068
|
**/test-results
|
|
827
1069
|
**/playwright-report
|
|
828
1070
|
**/.hai/deploy
|
|
1071
|
+
**/.env.local
|
|
1072
|
+
**/.env.*.local
|
|
1073
|
+
**/.env.runtime
|
|
829
1074
|
.git
|
|
830
1075
|
`;
|
|
831
1076
|
}
|
|
832
|
-
function generateFallbackDockerfile(packageName, containerPort) {
|
|
1077
|
+
function generateFallbackDockerfile(packageName, containerPort, options = {}) {
|
|
1078
|
+
if (!/^@?[a-z0-9][\w./-]*$/i.test(packageName) || !/^[a-z0-9][\w-]*$/i.test(options.environment ?? "prod"))
|
|
1079
|
+
throw new Error("Invalid container build target");
|
|
1080
|
+
const environment = options.environment ?? "prod";
|
|
1081
|
+
const coreEnv = environment === "prod" ? "production" : environment === "dev" ? "development" : environment === "test" ? "test" : "staging";
|
|
833
1082
|
return `# syntax=docker/dockerfile:1.7
|
|
834
1083
|
FROM node:24-bookworm-slim AS build
|
|
1084
|
+
ARG SQLITE_PREBUILD_MIRROR
|
|
1085
|
+
ENV npm_config_better_sqlite3_binary_host_mirror=$SQLITE_PREBUILD_MIRROR
|
|
835
1086
|
ENV PNPM_HOME=/pnpm
|
|
836
1087
|
ENV PATH=$PNPM_HOME:$PATH
|
|
837
1088
|
ENV CI=true
|
|
1089
|
+
ENV HAI_DEPLOY_ENVIRONMENT=${environment}
|
|
1090
|
+
ENV HAI_CORE_ENV=${coreEnv}
|
|
838
1091
|
WORKDIR /workspace
|
|
839
1092
|
RUN corepack enable && corepack prepare pnpm@12.11.2 --activate
|
|
840
1093
|
COPY . .
|
|
841
1094
|
RUN pnpm install --frozen-lockfile --ignore-scripts
|
|
842
|
-
RUN pnpm --filter ${packageName} build
|
|
1095
|
+
RUN pnpm --filter ${packageName}... build
|
|
1096
|
+
RUN if [ -d node_modules/better-sqlite3 ]; then cd node_modules/better-sqlite3; for attempt in 1 2 3; do node -e "require('node:child_process').execFileSync(process.execPath,[require.resolve('prebuild-install/bin.js'),'--verbose'],{stdio:'inherit'})" && break; [ "$attempt" = 3 ] && exit 1; sleep 3; done; fi
|
|
843
1097
|
RUN pnpm pm deploy --filter ${packageName} --prod /prod/app
|
|
844
1098
|
|
|
845
1099
|
FROM node:24-bookworm-slim AS runtime
|
|
846
1100
|
ENV NODE_ENV=production
|
|
1101
|
+
ENV HAI_DEPLOY_ENVIRONMENT=${environment}
|
|
1102
|
+
ENV HAI_CORE_ENV=${coreEnv}
|
|
847
1103
|
ENV HOST=0.0.0.0
|
|
848
1104
|
ENV PORT=${containerPort}
|
|
849
1105
|
WORKDIR /app
|
|
850
1106
|
COPY --from=build --chown=node:node /prod/app ./
|
|
1107
|
+
RUN mkdir -p data && chown node:node data
|
|
851
1108
|
USER node
|
|
852
1109
|
EXPOSE ${containerPort}
|
|
853
|
-
CMD ["node", "build"]
|
|
1110
|
+
CMD ["node", ${JSON.stringify(options.entrypoint ?? "build")}]
|
|
854
1111
|
`;
|
|
855
1112
|
}
|
|
856
1113
|
var logger4 = core.logger.child({ module: "deploy", scope: "command" });
|
|
@@ -865,10 +1122,10 @@ function maskSecrets(text, secrets) {
|
|
|
865
1122
|
return masked;
|
|
866
1123
|
}
|
|
867
1124
|
function runCommand(command, args, options = {}) {
|
|
868
|
-
return new Promise((
|
|
1125
|
+
return new Promise((resolve3) => {
|
|
869
1126
|
const child = spawn(command, args, {
|
|
870
1127
|
cwd: options.cwd,
|
|
871
|
-
env: options.env ? { ...
|
|
1128
|
+
env: options.env ? { ...process2.env, ...options.env } : process2.env,
|
|
872
1129
|
shell: false
|
|
873
1130
|
});
|
|
874
1131
|
let stdout = "";
|
|
@@ -881,7 +1138,7 @@ function runCommand(command, args, options = {}) {
|
|
|
881
1138
|
settled = true;
|
|
882
1139
|
if (timer)
|
|
883
1140
|
clearTimeout(timer);
|
|
884
|
-
|
|
1141
|
+
resolve3(result);
|
|
885
1142
|
};
|
|
886
1143
|
if (options.timeoutMs && options.timeoutMs > 0) {
|
|
887
1144
|
timer = setTimeout(() => {
|
|
@@ -918,12 +1175,14 @@ function runCommand(command, args, options = {}) {
|
|
|
918
1175
|
logger4.error("Command exited with non-zero code", {
|
|
919
1176
|
command,
|
|
920
1177
|
code: exitCode,
|
|
921
|
-
stderr: maskSecrets(stderr, options.secrets).slice(-500)
|
|
1178
|
+
stderr: maskSecrets(stderr, options.secrets).slice(-500),
|
|
1179
|
+
stdout: maskSecrets(stdout, options.secrets).slice(-3e3)
|
|
922
1180
|
});
|
|
923
1181
|
finish(err(
|
|
924
1182
|
HaiDeployError.REMOTE_COMMAND_FAILED,
|
|
925
1183
|
deployM("deploy_commandFailed", {
|
|
926
|
-
params: { command, error: maskSecrets(
|
|
1184
|
+
params: { command, error: maskSecrets(`${stdout.slice(-2500)}
|
|
1185
|
+
${stderr || `exit ${exitCode}`}`, options.secrets).slice(-3e3) }
|
|
927
1186
|
})
|
|
928
1187
|
));
|
|
929
1188
|
});
|
|
@@ -956,6 +1215,12 @@ async function buildImage(runtime, options) {
|
|
|
956
1215
|
const args = ["build", "-f", options.dockerfilePath, "-t", options.imageRef];
|
|
957
1216
|
if (runtime === "podman")
|
|
958
1217
|
args.push("--format", "docker", "--jobs", "1");
|
|
1218
|
+
if (runtime === "podman" && existsSync(`${options.dockerfilePath}.dockerignore`))
|
|
1219
|
+
args.push("--ignorefile", `${options.dockerfilePath}.dockerignore`);
|
|
1220
|
+
if (runtime === "podman" && options.httpProxy === false)
|
|
1221
|
+
args.push("--http-proxy=false");
|
|
1222
|
+
for (const [key, value] of Object.entries(options.buildArgs ?? {}))
|
|
1223
|
+
args.push("--build-arg", `${key}=${value}`);
|
|
959
1224
|
args.push(options.contextDir);
|
|
960
1225
|
logger5.info("Building container image", { runtime, imageRef: options.imageRef });
|
|
961
1226
|
const result = await runCommand(runtime, args, { cwd: options.contextDir });
|
|
@@ -1049,6 +1314,9 @@ function createSshExecutor(config) {
|
|
|
1049
1314
|
var logger7 = core.logger.child({ module: "deploy", scope: "provider-docker-ssh" });
|
|
1050
1315
|
var HEALTH_POLL_INTERVAL = 3e3;
|
|
1051
1316
|
var HEALTH_MAX_ATTEMPTS = 40;
|
|
1317
|
+
function shellArgument(value) {
|
|
1318
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
1319
|
+
}
|
|
1052
1320
|
function makeDeploymentId() {
|
|
1053
1321
|
const now = /* @__PURE__ */ new Date();
|
|
1054
1322
|
const pad = (n) => String(n).padStart(2, "0");
|
|
@@ -1060,7 +1328,7 @@ function parseLoadedImageRef(stdout, fallback) {
|
|
|
1060
1328
|
return match ? match[1] : fallback;
|
|
1061
1329
|
}
|
|
1062
1330
|
function sleep(ms) {
|
|
1063
|
-
return new Promise((
|
|
1331
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
1064
1332
|
}
|
|
1065
1333
|
function readPackageName(appDir) {
|
|
1066
1334
|
try {
|
|
@@ -1083,7 +1351,9 @@ function resolveWorkspaceRoot(appDir) {
|
|
|
1083
1351
|
}
|
|
1084
1352
|
return appDir;
|
|
1085
1353
|
}
|
|
1086
|
-
function createDockerSshProvider(config,
|
|
1354
|
+
function createDockerSshProvider(config, container) {
|
|
1355
|
+
const localRuntimePref = container?.runtime ?? "auto";
|
|
1356
|
+
const httpProxy = container?.httpProxy ?? true;
|
|
1087
1357
|
const ssh = createSshExecutor(config.ssh);
|
|
1088
1358
|
const baseDir = config.remote.baseDir;
|
|
1089
1359
|
async function detectRemoteRuntime() {
|
|
@@ -1116,7 +1386,7 @@ function createDockerSshProvider(config, localRuntimePref = "auto") {
|
|
|
1116
1386
|
return false;
|
|
1117
1387
|
}
|
|
1118
1388
|
const deploy2 = async (context) => {
|
|
1119
|
-
const { appDir, projectName, scan, provisionEnv } = context;
|
|
1389
|
+
const { appDir, projectName, scan, provisionEnv, appEnv, options } = context;
|
|
1120
1390
|
const deploymentId = makeDeploymentId();
|
|
1121
1391
|
const containerPort = config.expose.containerPort;
|
|
1122
1392
|
const hostPort = config.expose.hostPort;
|
|
@@ -1125,7 +1395,7 @@ function createDockerSshProvider(config, localRuntimePref = "auto") {
|
|
|
1125
1395
|
if (!runtimeResult.success)
|
|
1126
1396
|
return err(runtimeResult.error);
|
|
1127
1397
|
const localRuntime = runtimeResult.data;
|
|
1128
|
-
const artifactDir = join(appDir, ".hai", "deploy");
|
|
1398
|
+
const artifactDir = join(appDir, ".hai", "deploy", options?.environment ?? "prod");
|
|
1129
1399
|
mkdirSync(artifactDir, { recursive: true });
|
|
1130
1400
|
const appDockerfile = join(appDir, "Dockerfile");
|
|
1131
1401
|
let dockerfilePath;
|
|
@@ -1135,21 +1405,33 @@ function createDockerSshProvider(config, localRuntimePref = "auto") {
|
|
|
1135
1405
|
contextDir = appDir;
|
|
1136
1406
|
} else {
|
|
1137
1407
|
dockerfilePath = join(artifactDir, "Dockerfile");
|
|
1138
|
-
writeFileSync(dockerfilePath, generateFallbackDockerfile(readPackageName(appDir), containerPort));
|
|
1408
|
+
writeFileSync(dockerfilePath, generateFallbackDockerfile(readPackageName(appDir), containerPort, { environment: options?.environment, entrypoint: scan.isSvelteKit ? "build" : "dist/index.js" }));
|
|
1139
1409
|
writeFileSync(`${dockerfilePath}.dockerignore`, generateDockerignore());
|
|
1140
1410
|
contextDir = resolveWorkspaceRoot(appDir);
|
|
1141
1411
|
logger7.info("Generated fallback Dockerfile", { dockerfilePath, contextDir });
|
|
1142
1412
|
}
|
|
1413
|
+
const { env, sidecar } = buildRuntimeEnv(appDir, projectName, scan, provisionEnv, containerPort, options, appEnv, container?.s3Engine);
|
|
1414
|
+
const envPath = join(artifactDir, ".env.runtime");
|
|
1415
|
+
const previousEnv = existsSync(envPath) ? parseEnv(readFileSync(envPath, "utf8")) : {};
|
|
1416
|
+
Object.assign(env, { ...previousEnv, ...env });
|
|
1417
|
+
writeFileSync(envPath, serializeEnvFile(env), { mode: 384 });
|
|
1418
|
+
if (options?.environment) {
|
|
1419
|
+
const environment = loadDeploymentEnvironment({ environment: options.environment, envDir: options.envDir ?? scan.envDir ?? appDir });
|
|
1420
|
+
if (!environment.success)
|
|
1421
|
+
return environment;
|
|
1422
|
+
const saved = saveDeploymentEnvironment(environment.data, { HAI_DEPLOY_ENVIRONMENT: environment.data.environment, HAI_CORE_ENV: environment.data.values.HAI_CORE_ENV, ...sidecar.env, ...provisionEnv });
|
|
1423
|
+
if (!saved.success)
|
|
1424
|
+
return saved;
|
|
1425
|
+
}
|
|
1143
1426
|
const buildResult = await buildImage(localRuntime, {
|
|
1144
1427
|
contextDir,
|
|
1145
1428
|
dockerfilePath,
|
|
1146
|
-
imageRef: localImageTag
|
|
1429
|
+
imageRef: localImageTag,
|
|
1430
|
+
buildArgs: { HAI_DEPLOY_ENVIRONMENT: options?.environment ?? "prod", HAI_CORE_ENV: env.HAI_CORE_ENV ?? "production", SQLITE_PREBUILD_MIRROR: container?.sqlitePrebuildMirror ?? "" },
|
|
1431
|
+
httpProxy
|
|
1147
1432
|
});
|
|
1148
1433
|
if (!buildResult.success)
|
|
1149
1434
|
return err(buildResult.error);
|
|
1150
|
-
const { env, sidecar } = buildRuntimeEnv(appDir, projectName, scan, provisionEnv, containerPort);
|
|
1151
|
-
const envPath = join(artifactDir, ".env.runtime");
|
|
1152
|
-
writeFileSync(envPath, serializeEnvFile(env), { mode: 384 });
|
|
1153
1435
|
const tarPath = join(artifactDir, `${projectName}.tar`);
|
|
1154
1436
|
const saveResult = await saveImage(localRuntime, localImageTag, tarPath);
|
|
1155
1437
|
if (!saveResult.success)
|
|
@@ -1162,20 +1444,20 @@ function createDockerSshProvider(config, localRuntimePref = "auto") {
|
|
|
1162
1444
|
return err(remoteRuntimeResult.error);
|
|
1163
1445
|
const remoteRuntime = remoteRuntimeResult.data;
|
|
1164
1446
|
const remoteDir = `${baseDir}/${projectName}`;
|
|
1165
|
-
const prepareResult = await ssh.exec(`mkdir -p ${remoteDir} && chmod 700 ${remoteDir}`);
|
|
1447
|
+
const prepareResult = await ssh.exec(`mkdir -p -- ${shellArgument(remoteDir)} && chmod 700 -- ${shellArgument(remoteDir)}`);
|
|
1166
1448
|
if (!prepareResult.success)
|
|
1167
1449
|
return err(prepareResult.error);
|
|
1168
1450
|
const remoteTar = `${remoteDir}/image.tar`;
|
|
1169
1451
|
const uploadTarResult = await ssh.upload(tarPath, remoteTar);
|
|
1170
1452
|
if (!uploadTarResult.success)
|
|
1171
1453
|
return err(uploadTarResult.error);
|
|
1172
|
-
const loadResult = await ssh.exec(`${remoteRuntime} load -i ${remoteTar}`, { timeoutMs: 3e5 });
|
|
1454
|
+
const loadResult = await ssh.exec(`${remoteRuntime} load -i ${shellArgument(remoteTar)}`, { timeoutMs: 3e5 });
|
|
1173
1455
|
if (!loadResult.success)
|
|
1174
1456
|
return err(loadResult.error);
|
|
1175
1457
|
const loadedRef = parseLoadedImageRef(loadResult.data.stdout, localImageTag);
|
|
1176
1458
|
const remoteImageRef = remoteRuntime === "podman" ? `localhost/${localImageTag}` : localImageTag;
|
|
1177
1459
|
if (loadedRef !== remoteImageRef) {
|
|
1178
|
-
const tagResult = await ssh.exec(`${remoteRuntime} tag ${loadedRef} ${remoteImageRef}`);
|
|
1460
|
+
const tagResult = await ssh.exec(`${remoteRuntime} tag ${shellArgument(loadedRef)} ${shellArgument(remoteImageRef)}`);
|
|
1179
1461
|
if (!tagResult.success)
|
|
1180
1462
|
return err(tagResult.error);
|
|
1181
1463
|
}
|
|
@@ -1184,7 +1466,9 @@ function createDockerSshProvider(config, localRuntimePref = "auto") {
|
|
|
1184
1466
|
imageRef: remoteImageRef,
|
|
1185
1467
|
hostPort,
|
|
1186
1468
|
containerPort,
|
|
1187
|
-
sidecar
|
|
1469
|
+
sidecar,
|
|
1470
|
+
serviceModes: options?.serviceModes,
|
|
1471
|
+
previousCompose: existsSync(join(artifactDir, "compose.yml")) ? readFileSync(join(artifactDir, "compose.yml"), "utf8") : void 0
|
|
1188
1472
|
});
|
|
1189
1473
|
const composePath = join(artifactDir, "compose.yml");
|
|
1190
1474
|
writeFileSync(composePath, composeContent);
|
|
@@ -1194,20 +1478,33 @@ function createDockerSshProvider(config, localRuntimePref = "auto") {
|
|
|
1194
1478
|
const uploadEnvResult = await ssh.upload(envPath, `${remoteDir}/.env.runtime`);
|
|
1195
1479
|
if (!uploadEnvResult.success)
|
|
1196
1480
|
return err(uploadEnvResult.error);
|
|
1481
|
+
const startScript = `#!/bin/sh
|
|
1482
|
+
set -eu
|
|
1483
|
+
cd "$(dirname "$0")"
|
|
1484
|
+
export HAI_DEPLOY_ENVIRONMENT=${options?.environment ?? "prod"}
|
|
1485
|
+
export HAI_CORE_ENV=${shellArgument(env.HAI_CORE_ENV ?? "production")}
|
|
1486
|
+
exec ${remoteRuntime} compose --env-file .env.runtime -p hai-${projectName} up -d
|
|
1487
|
+
`;
|
|
1488
|
+
const startName = `start-${options?.environment ?? "prod"}.sh`;
|
|
1489
|
+
const startPath = join(artifactDir, startName);
|
|
1490
|
+
writeFileSync(startPath, startScript, { mode: 448 });
|
|
1491
|
+
const uploadStart = await ssh.upload(startPath, `${remoteDir}/${startName}`);
|
|
1492
|
+
if (!uploadStart.success)
|
|
1493
|
+
return uploadStart;
|
|
1197
1494
|
const secretMask = sidecar.secrets;
|
|
1198
|
-
const chmodResult = await ssh.exec(`chmod 600 ${remoteDir}/.env.runtime`, { secrets: secretMask });
|
|
1495
|
+
const chmodResult = await ssh.exec(`chmod 600 -- ${shellArgument(`${remoteDir}/.env.runtime`)}`, { secrets: secretMask });
|
|
1199
1496
|
if (!chmodResult.success)
|
|
1200
1497
|
return err(chmodResult.error);
|
|
1201
1498
|
const upResult = await ssh.exec(
|
|
1202
|
-
`cd ${remoteDir} && ${
|
|
1499
|
+
`cd ${shellArgument(remoteDir)} && sh ${shellArgument(startName)}`,
|
|
1203
1500
|
{ timeoutMs: 3e5, secrets: secretMask }
|
|
1204
1501
|
);
|
|
1205
1502
|
if (!upResult.success)
|
|
1206
1503
|
return err(upResult.error);
|
|
1207
|
-
await ssh.exec(`rm -f ${remoteTar}`);
|
|
1504
|
+
await ssh.exec(`rm -f -- ${shellArgument(remoteTar)}`);
|
|
1208
1505
|
const url = `http://${config.ssh.host}:${hostPort}`;
|
|
1209
1506
|
logger7.info("Running health check", { url });
|
|
1210
|
-
const healthy = await healthCheck(`${url}
|
|
1507
|
+
const healthy = await healthCheck(`${url}${scan.isSvelteKit ? "/" : "/health"}`);
|
|
1211
1508
|
if (!healthy) {
|
|
1212
1509
|
return err(
|
|
1213
1510
|
HaiDeployError.HEALTH_CHECK_FAILED,
|
|
@@ -1462,7 +1759,7 @@ async function pollDeploymentStatus(token, deploymentId) {
|
|
|
1462
1759
|
if (deployment.readyState === "ERROR" || deployment.readyState === "CANCELED") {
|
|
1463
1760
|
return "error";
|
|
1464
1761
|
}
|
|
1465
|
-
await new Promise((
|
|
1762
|
+
await new Promise((resolve3) => setTimeout(resolve3, POLL_INTERVAL));
|
|
1466
1763
|
}
|
|
1467
1764
|
return "building";
|
|
1468
1765
|
}
|
|
@@ -2000,15 +2297,31 @@ function normalizeProjectName(name) {
|
|
|
2000
2297
|
const normalized = name.replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2001
2298
|
return normalized || "app";
|
|
2002
2299
|
}
|
|
2003
|
-
function mergeProvisionEnv(results) {
|
|
2300
|
+
function mergeProvisionEnv(results, scan, options) {
|
|
2004
2301
|
let env = {};
|
|
2005
|
-
const
|
|
2302
|
+
const reachRequirements = scan?.middlewares?.filter((item) => item.configName === "reach") ?? [];
|
|
2303
|
+
const reachProviders = reachRequirements.map((item) => item.config);
|
|
2304
|
+
let hasReachOutput = false;
|
|
2006
2305
|
for (const prov of results) {
|
|
2007
|
-
if (prov.envVars.HAI_REACH_PROVIDERS)
|
|
2008
|
-
|
|
2306
|
+
if (prov.envVars.HAI_REACH_PROVIDERS) {
|
|
2307
|
+
hasReachOutput = true;
|
|
2308
|
+
const supplied = JSON.parse(prov.envVars.HAI_REACH_PROVIDERS);
|
|
2309
|
+
for (const provider of supplied) {
|
|
2310
|
+
const targets = reachRequirements.filter((item) => {
|
|
2311
|
+
const mode = options?.serviceModes?.[item.envPrefix] ?? options?.serviceModes?.[item.service];
|
|
2312
|
+
return item.service === prov.serviceType && (!mode || mode === "default");
|
|
2313
|
+
});
|
|
2314
|
+
if (targets.length) {
|
|
2315
|
+
for (const target of targets)
|
|
2316
|
+
reachProviders[reachRequirements.indexOf(target)] = { ...provider, name: target.config.name ?? provider.name };
|
|
2317
|
+
} else {
|
|
2318
|
+
reachProviders.push(provider);
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2009
2322
|
env = { ...env, ...prov.envVars };
|
|
2010
2323
|
}
|
|
2011
|
-
if (
|
|
2324
|
+
if (hasReachOutput)
|
|
2012
2325
|
env.HAI_REACH_PROVIDERS = JSON.stringify(reachProviders);
|
|
2013
2326
|
return env;
|
|
2014
2327
|
}
|
|
@@ -2043,7 +2356,7 @@ var deploy = {
|
|
|
2043
2356
|
let provider = null;
|
|
2044
2357
|
let containerProvider = null;
|
|
2045
2358
|
if (parsed.provider.type === "docker-ssh") {
|
|
2046
|
-
containerProvider = createDockerSshProvider(parsed.provider, parsed.container
|
|
2359
|
+
containerProvider = createDockerSshProvider(parsed.provider, parsed.container);
|
|
2047
2360
|
logger14.info("Container deploy provider ready", { provider: "docker-ssh", host: parsed.provider.ssh.host });
|
|
2048
2361
|
} else {
|
|
2049
2362
|
provider = createProviderByType(parsed.provider.type);
|
|
@@ -2121,17 +2434,19 @@ var deploy = {
|
|
|
2121
2434
|
get isInitialized() {
|
|
2122
2435
|
return currentProvider !== null || currentContainerProvider !== null;
|
|
2123
2436
|
},
|
|
2124
|
-
async scan(appDir) {
|
|
2437
|
+
async scan(appDir, options) {
|
|
2125
2438
|
logger14.debug("Scanning application", { appDir });
|
|
2126
|
-
return scanApp(appDir);
|
|
2439
|
+
return scanApp(appDir, options);
|
|
2127
2440
|
},
|
|
2128
|
-
async provisionAll(projectName) {
|
|
2441
|
+
async provisionAll(projectName, services) {
|
|
2129
2442
|
if (!currentProvider && !currentContainerProvider) {
|
|
2130
2443
|
return notInitialized.result();
|
|
2131
2444
|
}
|
|
2132
2445
|
logger14.info("Provisioning all services", { projectName, count: currentProvisioners.size });
|
|
2133
2446
|
const results = [];
|
|
2134
2447
|
for (const [serviceType, provisioner] of currentProvisioners) {
|
|
2448
|
+
if (services && !services.includes(serviceType))
|
|
2449
|
+
continue;
|
|
2135
2450
|
logger14.debug("Provisioning service", { serviceType, provisioner: provisioner.name });
|
|
2136
2451
|
const provResult = await provisioner.provision(projectName);
|
|
2137
2452
|
if (!provResult.success) {
|
|
@@ -2160,15 +2475,26 @@ var deploy = {
|
|
|
2160
2475
|
if (!currentProvider && !currentContainerProvider) {
|
|
2161
2476
|
return notInitialized.result();
|
|
2162
2477
|
}
|
|
2163
|
-
logger14.info("Starting deployment", { appDir, options });
|
|
2164
|
-
const
|
|
2478
|
+
logger14.info("Starting deployment", { appDir, environment: options?.environment, projectName: options?.projectName });
|
|
2479
|
+
const scanOptions = { ...options, environment: options?.environment ?? currentConfig?.environment };
|
|
2480
|
+
const scanResult = await scanApp(appDir, scanOptions);
|
|
2165
2481
|
if (!scanResult.success) {
|
|
2166
2482
|
return err(scanResult.error);
|
|
2167
2483
|
}
|
|
2168
2484
|
const scan = scanResult.data;
|
|
2169
|
-
|
|
2485
|
+
appDir = scan.appDir ?? appDir;
|
|
2486
|
+
const environment = loadDeploymentEnvironment({ environment: scanOptions.environment ?? "prod", envDir: scan.envDir ?? appDir, requireFile: scanOptions.environment !== void 0 });
|
|
2487
|
+
if (!environment.success)
|
|
2488
|
+
return environment;
|
|
2489
|
+
const appEnv = Object.fromEntries(Object.entries(environment.data.values).filter(([key]) => !key.startsWith("HAI_CLI_") && (!key.startsWith("HAI_DEPLOY_") || key === "HAI_DEPLOY_ENVIRONMENT")));
|
|
2490
|
+
const projectName = `${options?.projectName ?? scan.appName}${scanOptions.environment ? `-${environment.data.environment}` : ""}`;
|
|
2491
|
+
options = {
|
|
2492
|
+
...options,
|
|
2493
|
+
...scanOptions.environment ? { environment: environment.data.environment } : {},
|
|
2494
|
+
...options?.skipProvision ? { serviceModes: Object.fromEntries(scan.requiredServices.map((service) => [service, "keep"])) } : {}
|
|
2495
|
+
};
|
|
2170
2496
|
if (currentContainerProvider) {
|
|
2171
|
-
return deployContainerApp(currentContainerProvider, appDir, scan, projectName, options);
|
|
2497
|
+
return deployContainerApp(currentContainerProvider, appDir, scan, projectName, options, appEnv);
|
|
2172
2498
|
}
|
|
2173
2499
|
if (!currentProvider) {
|
|
2174
2500
|
return notInitialized.result();
|
|
@@ -2181,15 +2507,20 @@ var deploy = {
|
|
|
2181
2507
|
})
|
|
2182
2508
|
);
|
|
2183
2509
|
}
|
|
2184
|
-
let allEnvVars =
|
|
2510
|
+
let allEnvVars = appEnv;
|
|
2185
2511
|
const recovery = { projectName, stage: "provision", resources: [] };
|
|
2186
2512
|
if (!options?.skipProvision) {
|
|
2187
|
-
const provResults = await deploy.provisionAll(projectName);
|
|
2513
|
+
const provResults = await deploy.provisionAll(projectName, options?.serviceModes ? scan.requiredServices.filter((service) => usesDefaultMiddleware(scan, service, options)) : void 0);
|
|
2188
2514
|
if (!provResults.success) {
|
|
2189
2515
|
return err(provResults.error);
|
|
2190
2516
|
}
|
|
2191
2517
|
recovery.resources = provResults.data.map(({ envVars: _envVars, ...resource }) => resource);
|
|
2192
|
-
allEnvVars = mergeProvisionEnv(provResults.data);
|
|
2518
|
+
allEnvVars = { ...allEnvVars, ...mergeProvisionEnv(provResults.data, scan, options) };
|
|
2519
|
+
if (options?.environment) {
|
|
2520
|
+
const saved = saveDeploymentEnvironment(environment.data, mergeProvisionEnv(provResults.data, scan, options));
|
|
2521
|
+
if (!saved.success)
|
|
2522
|
+
return recoveryFailure(saved.error, { ...recovery, stage: "save-env" });
|
|
2523
|
+
}
|
|
2193
2524
|
}
|
|
2194
2525
|
const projectResult = await currentProvider.createProject(projectName);
|
|
2195
2526
|
if (!projectResult.success) {
|
|
@@ -2226,7 +2557,7 @@ var deploy = {
|
|
|
2226
2557
|
return ok(result);
|
|
2227
2558
|
}
|
|
2228
2559
|
};
|
|
2229
|
-
async function deployContainerApp(provider, appDir, scan, rawProjectName, options) {
|
|
2560
|
+
async function deployContainerApp(provider, appDir, scan, rawProjectName, options, appEnv = {}) {
|
|
2230
2561
|
const projectName = normalizeProjectName(rawProjectName);
|
|
2231
2562
|
if (scan.isSvelteKit && !scan.nodeAdapterInstalled) {
|
|
2232
2563
|
return err(
|
|
@@ -2236,15 +2567,60 @@ async function deployContainerApp(provider, appDir, scan, rawProjectName, option
|
|
|
2236
2567
|
}
|
|
2237
2568
|
let provisionEnv = {};
|
|
2238
2569
|
if (!options?.skipProvision) {
|
|
2239
|
-
const provResults = await deploy.provisionAll(projectName);
|
|
2570
|
+
const provResults = await deploy.provisionAll(projectName, options?.serviceModes ? scan.requiredServices.filter((service) => usesDefaultMiddleware(scan, service, options)) : void 0);
|
|
2240
2571
|
if (!provResults.success) {
|
|
2241
2572
|
return err(provResults.error);
|
|
2242
2573
|
}
|
|
2243
|
-
provisionEnv = mergeProvisionEnv(provResults.data);
|
|
2574
|
+
provisionEnv = mergeProvisionEnv(provResults.data, scan, options);
|
|
2244
2575
|
}
|
|
2245
|
-
return provider.deploy({ appDir, projectName, scan, provisionEnv });
|
|
2576
|
+
return provider.deploy({ appDir, projectName, scan, provisionEnv, appEnv, options });
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
// src/deploy-middleware.ts
|
|
2580
|
+
var field = (name, defaultValue, kind, secret = false) => ({ name, defaultValue, kind, secret });
|
|
2581
|
+
var required = (name, defaultValue, kind) => ({ ...field(name, defaultValue, kind), required: true });
|
|
2582
|
+
var DEPLOY_MIDDLEWARE = {
|
|
2583
|
+
db: [
|
|
2584
|
+
{ backend: "sqlite", label: "SQLite", purpose: "deploy_purposeSqlite", fields: [required("database", "./data/app.db")] },
|
|
2585
|
+
...["postgresql", "mysql"].map((backend) => ({
|
|
2586
|
+
backend,
|
|
2587
|
+
label: backend === "mysql" ? "MySQL" : "PostgreSQL",
|
|
2588
|
+
purpose: "deploy_purposeDatabase",
|
|
2589
|
+
provisioner: backend === "postgresql" ? "neon" : void 0,
|
|
2590
|
+
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")]
|
|
2591
|
+
}))
|
|
2592
|
+
],
|
|
2593
|
+
cache: [
|
|
2594
|
+
{ backend: "memory", label: "Memory", purpose: "deploy_purposeMemory", fields: [] },
|
|
2595
|
+
{ 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")] }
|
|
2596
|
+
],
|
|
2597
|
+
storage: [
|
|
2598
|
+
{ backend: "local", label: "Local filesystem", purpose: "deploy_purposeLocal", fields: [required("root", "./data/storage")] },
|
|
2599
|
+
{ 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")] }
|
|
2600
|
+
],
|
|
2601
|
+
vecdb: [
|
|
2602
|
+
{ backend: "lancedb", label: "LanceDB", purpose: "deploy_purposeLocal", fields: [required("path", "./data/vectors")] },
|
|
2603
|
+
{ backend: "qdrant", label: "Qdrant", purpose: "deploy_purposeVector", fields: [required("url", "http://localhost:6333", "url"), field("apiKey", "", void 0, true)] },
|
|
2604
|
+
{ 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)] },
|
|
2605
|
+
{ backend: "chroma", label: "Chroma", purpose: "deploy_purposeVector", fields: [required("url", "http://localhost:8000", "url")] }
|
|
2606
|
+
],
|
|
2607
|
+
ldap: [
|
|
2608
|
+
{ backend: "disabled", label: "Disabled", purpose: "deploy_purposeDisabled", fields: [] },
|
|
2609
|
+
{ 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 }] }
|
|
2610
|
+
],
|
|
2611
|
+
email: [
|
|
2612
|
+
{ backend: "console", label: "Console", purpose: "deploy_purposeConsole", fields: [] },
|
|
2613
|
+
{ 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")] },
|
|
2614
|
+
{ backend: "api", label: "HTTP notification API", purpose: "deploy_purposeEmail", fields: [required("url", "", "url"), field("method", "POST"), field("headers"), field("timeout", "10000", "number")] }
|
|
2615
|
+
],
|
|
2616
|
+
sms: [
|
|
2617
|
+
{ 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")] }
|
|
2618
|
+
]
|
|
2619
|
+
};
|
|
2620
|
+
function describeMiddlewareBackend(backend) {
|
|
2621
|
+
return deployM(backend.purpose);
|
|
2246
2622
|
}
|
|
2247
2623
|
|
|
2248
|
-
export { DeployConfigSchema, HaiDeployError, ProviderConfigSchema, deploy, getDeployRecovery };
|
|
2624
|
+
export { DEPLOY_MIDDLEWARE, DeployConfigSchema, HaiDeployError, ProviderConfigSchema, deploy, describeMiddlewareBackend, getDeployRecovery, loadDeploymentConfig, loadDeploymentEnvironment, saveDeploymentEnvironment };
|
|
2249
2625
|
//# sourceMappingURL=index.js.map
|
|
2250
2626
|
//# sourceMappingURL=index.js.map
|