@h-ai/deploy 0.1.0-alpha.54 → 0.1.0-alpha.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -3
- package/dist/index.d.ts +119 -2
- package/dist/index.js +906 -125
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -5,8 +5,8 @@ import { homedir } from 'os';
|
|
|
5
5
|
import { join, dirname, relative, sep } from 'path';
|
|
6
6
|
import process from 'process';
|
|
7
7
|
import { parse, stringify } from 'yaml';
|
|
8
|
-
import { execSync } from 'child_process';
|
|
9
|
-
import { createHash } from 'crypto';
|
|
8
|
+
import { execSync, spawn } from 'child_process';
|
|
9
|
+
import { randomBytes, createHash } from 'crypto';
|
|
10
10
|
import { Buffer } from 'buffer';
|
|
11
11
|
|
|
12
12
|
// src/deploy-config.ts
|
|
@@ -38,7 +38,15 @@ var en_US_default = {
|
|
|
38
38
|
deploy_fileUploadFailed: "File upload failed ({status})",
|
|
39
39
|
deploy_provisionNoResult: "No connection info returned from {service}",
|
|
40
40
|
deploy_recoveryFailure: "Deployment stopped at {stage}; inspect the recovery details.",
|
|
41
|
-
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."
|
|
41
|
+
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
|
+
deploy_commandTimeout: "Command timed out: {command}",
|
|
43
|
+
deploy_commandFailed: "Command failed ({command}): {error}",
|
|
44
|
+
deploy_containerRuntimeNotFound: "No usable container runtime found: {runtime}",
|
|
45
|
+
deploy_containerBuildFailed: "Container image build failed: {error}",
|
|
46
|
+
deploy_remoteConnectFailed: "Cannot connect to remote host {host}: {error}",
|
|
47
|
+
deploy_remoteRuntimeMissing: "Remote host missing container runtime or compose: {runtime}",
|
|
48
|
+
deploy_imageTransferFailed: "Image transfer failed: {error}",
|
|
49
|
+
deploy_healthCheckFailed: "Health check failed, app not ready at {url}"
|
|
42
50
|
};
|
|
43
51
|
|
|
44
52
|
// messages/zh-CN.json
|
|
@@ -68,7 +76,15 @@ var zh_CN_default = {
|
|
|
68
76
|
deploy_fileUploadFailed: "\u6587\u4EF6\u4E0A\u4F20\u5931\u8D25\uFF08{status}\uFF09",
|
|
69
77
|
deploy_provisionNoResult: "{service} \u672A\u8FD4\u56DE\u8FDE\u63A5\u4FE1\u606F",
|
|
70
78
|
deploy_recoveryFailure: "\u90E8\u7F72\u5728 {stage} \u9636\u6BB5\u505C\u6B62\uFF0C\u8BF7\u67E5\u770B\u6062\u590D\u6E05\u5355\u3002",
|
|
71
|
-
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"
|
|
79
|
+
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
|
+
deploy_commandTimeout: "\u547D\u4EE4\u6267\u884C\u8D85\u65F6\uFF1A{command}",
|
|
81
|
+
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
|
+
deploy_remoteConnectFailed: "\u65E0\u6CD5\u8FDE\u63A5\u8FDC\u7A0B\u4E3B\u673A {host}\uFF1A{error}",
|
|
85
|
+
deploy_remoteRuntimeMissing: "\u8FDC\u7A0B\u4E3B\u673A\u7F3A\u5C11\u5BB9\u5668\u8FD0\u884C\u65F6\u6216 compose\uFF1A{runtime}",
|
|
86
|
+
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"
|
|
72
88
|
};
|
|
73
89
|
|
|
74
90
|
// src/deploy-i18n.ts
|
|
@@ -82,8 +98,47 @@ var VercelProviderConfigSchema = z.object({
|
|
|
82
98
|
type: z.literal("vercel"),
|
|
83
99
|
token: z.string().min(1, deployM("deploy_configTokenRequired"))
|
|
84
100
|
});
|
|
101
|
+
var SshConfigSchema = z.object({
|
|
102
|
+
/** 远程主机地址或 ~/.ssh/config 中的 Host 别名 */
|
|
103
|
+
host: z.string().min(1),
|
|
104
|
+
/** SSH 端口 */
|
|
105
|
+
port: z.number().int().min(1).max(65535).default(22),
|
|
106
|
+
/** SSH 用户名 */
|
|
107
|
+
username: z.string().min(1),
|
|
108
|
+
/** 私钥文件路径(省略时使用 ssh-agent 或 ~/.ssh 默认密钥) */
|
|
109
|
+
identityFile: z.string().min(1).optional(),
|
|
110
|
+
/** known_hosts 文件路径(省略时使用默认;配合 CI/临时环境隔离主机指纹) */
|
|
111
|
+
knownHostsFile: z.string().min(1).optional(),
|
|
112
|
+
/** 严格主机密钥校验,默认开启(true→accept-new 首次信任并拒绝变更;false→关闭校验) */
|
|
113
|
+
strictHostKeyChecking: z.boolean().default(true),
|
|
114
|
+
/** 连接超时(秒) */
|
|
115
|
+
connectTimeoutSeconds: z.number().int().positive().default(10)
|
|
116
|
+
});
|
|
117
|
+
var PortExposeSchema = z.object({
|
|
118
|
+
type: z.literal("port").default("port"),
|
|
119
|
+
/** 容器内监听端口(adapter-node 默认 3000) */
|
|
120
|
+
containerPort: z.number().int().positive().default(3e3),
|
|
121
|
+
/** 宿主机映射端口 */
|
|
122
|
+
hostPort: z.number().int().positive()
|
|
123
|
+
});
|
|
124
|
+
var ExposeSchema = PortExposeSchema;
|
|
125
|
+
var DockerSshProviderConfigSchema = z.object({
|
|
126
|
+
type: z.literal("docker-ssh"),
|
|
127
|
+
/** SSH 连接参数 */
|
|
128
|
+
ssh: SshConfigSchema,
|
|
129
|
+
/** 远程主机运行参数 */
|
|
130
|
+
remote: z.object({
|
|
131
|
+
/** 远程应用根目录 */
|
|
132
|
+
baseDir: z.string().min(1).default("/opt/hai/apps"),
|
|
133
|
+
/** 远程容器运行时(auto 优先 podman) */
|
|
134
|
+
runtime: z.enum(["auto", "docker", "podman"]).default("auto")
|
|
135
|
+
}).default({ baseDir: "/opt/hai/apps", runtime: "auto" }),
|
|
136
|
+
/** 应用暴露方式 */
|
|
137
|
+
expose: ExposeSchema
|
|
138
|
+
});
|
|
85
139
|
var ProviderConfigSchema = z.discriminatedUnion("type", [
|
|
86
|
-
VercelProviderConfigSchema
|
|
140
|
+
VercelProviderConfigSchema,
|
|
141
|
+
DockerSshProviderConfigSchema
|
|
87
142
|
]);
|
|
88
143
|
var NeonServiceSchema = z.object({
|
|
89
144
|
provisioner: z.literal("neon"),
|
|
@@ -121,7 +176,11 @@ var ServicesConfigSchema = z.object({
|
|
|
121
176
|
}).optional();
|
|
122
177
|
var DeployConfigSchema = z.object({
|
|
123
178
|
provider: ProviderConfigSchema,
|
|
124
|
-
services: ServicesConfigSchema
|
|
179
|
+
services: ServicesConfigSchema,
|
|
180
|
+
/** 本地容器构建参数(docker-ssh 使用;auto 优先 podman) */
|
|
181
|
+
container: z.object({
|
|
182
|
+
runtime: z.enum(["auto", "docker", "podman"]).default("auto")
|
|
183
|
+
}).optional()
|
|
125
184
|
});
|
|
126
185
|
var DeployErrorInfo = {
|
|
127
186
|
DEPLOY_FAILED: "001:500",
|
|
@@ -137,7 +196,13 @@ var DeployErrorInfo = {
|
|
|
137
196
|
ENV_VAR_FAILED: "011:500",
|
|
138
197
|
UNSUPPORTED_TYPE: "012:400",
|
|
139
198
|
CONFIG_ERROR: "013:500",
|
|
140
|
-
CREDENTIAL_ERROR: "014:500"
|
|
199
|
+
CREDENTIAL_ERROR: "014:500",
|
|
200
|
+
CONTAINER_RUNTIME_NOT_FOUND: "015:500",
|
|
201
|
+
CONTAINER_BUILD_FAILED: "016:500",
|
|
202
|
+
REMOTE_CONNECT_FAILED: "017:500",
|
|
203
|
+
REMOTE_COMMAND_FAILED: "018:500",
|
|
204
|
+
IMAGE_TRANSFER_FAILED: "019:500",
|
|
205
|
+
HEALTH_CHECK_FAILED: "020:500"
|
|
141
206
|
};
|
|
142
207
|
var HaiDeployError = core.error.buildHaiErrorsDef("deploy", DeployErrorInfo);
|
|
143
208
|
|
|
@@ -355,55 +420,61 @@ function hasDependency(pkg, name) {
|
|
|
355
420
|
}
|
|
356
421
|
function detectRequiredServices(configDir) {
|
|
357
422
|
const services = [];
|
|
423
|
+
const backends = {};
|
|
358
424
|
if (!existsSync(configDir)) {
|
|
359
|
-
return services;
|
|
425
|
+
return { services, backends };
|
|
360
426
|
}
|
|
361
427
|
const files = readdirSync(configDir);
|
|
362
|
-
|
|
363
|
-
|
|
428
|
+
const dbFile = files.find((f) => f.startsWith("_db."));
|
|
429
|
+
if (dbFile) {
|
|
430
|
+
const dbConfig = readYaml(join(configDir, dbFile));
|
|
364
431
|
if (dbConfig !== null) {
|
|
365
432
|
services.push("db");
|
|
433
|
+
backends.db = dbConfig.type ?? "postgresql";
|
|
366
434
|
}
|
|
367
435
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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";
|
|
375
450
|
}
|
|
376
451
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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";
|
|
381
458
|
}
|
|
382
459
|
}
|
|
383
|
-
|
|
384
|
-
|
|
460
|
+
const reachFile = files.find((f) => f.startsWith("_reach."));
|
|
461
|
+
if (reachFile) {
|
|
462
|
+
const reachConfig = readYaml(join(configDir, reachFile));
|
|
385
463
|
if (reachConfig !== null) {
|
|
386
464
|
detectReachServices(reachConfig, services);
|
|
387
465
|
}
|
|
388
466
|
}
|
|
389
|
-
return services;
|
|
467
|
+
return { services, backends };
|
|
390
468
|
}
|
|
391
|
-
function
|
|
469
|
+
function detectStorageIsS3(config) {
|
|
392
470
|
if (config.type === "s3") {
|
|
393
|
-
|
|
394
|
-
return;
|
|
471
|
+
return true;
|
|
395
472
|
}
|
|
396
473
|
const providers = config.providers;
|
|
397
474
|
if (providers === void 0) {
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
for (const providerConfig of Object.values(providers)) {
|
|
401
|
-
const typed = providerConfig;
|
|
402
|
-
if (typed?.type === "s3") {
|
|
403
|
-
services.push("storage");
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
475
|
+
return false;
|
|
406
476
|
}
|
|
477
|
+
return Object.values(providers).some((p) => p?.type === "s3");
|
|
407
478
|
}
|
|
408
479
|
function detectReachServices(config, services) {
|
|
409
480
|
const providers = config.providers;
|
|
@@ -437,24 +508,29 @@ async function scanApp(appDir) {
|
|
|
437
508
|
const appName = extractAppName(pkg);
|
|
438
509
|
const isSvelteKit = existsSync(join(appDir, "svelte.config.js")) || existsSync(join(appDir, "svelte.config.ts"));
|
|
439
510
|
const adapterInstalled = hasDependency(pkg, "@sveltejs/adapter-vercel");
|
|
511
|
+
const nodeAdapterInstalled = hasDependency(pkg, "@sveltejs/adapter-node");
|
|
440
512
|
const configDir = join(appDir, "config");
|
|
441
|
-
const
|
|
513
|
+
const configDetection = detectRequiredServices(configDir);
|
|
442
514
|
const depServices = detectServicesFromDependencies(pkg);
|
|
443
|
-
const requiredServices = Array.from(/* @__PURE__ */ new Set([...
|
|
515
|
+
const requiredServices = Array.from(/* @__PURE__ */ new Set([...configDetection.services, ...depServices]));
|
|
444
516
|
const scripts = pkg.scripts;
|
|
445
517
|
const buildCommand = scripts?.build ?? "pnpm build";
|
|
446
518
|
const scanResult = {
|
|
447
519
|
appName,
|
|
448
520
|
isSvelteKit,
|
|
449
521
|
adapterInstalled,
|
|
522
|
+
nodeAdapterInstalled,
|
|
450
523
|
requiredServices,
|
|
524
|
+
serviceBackends: configDetection.backends,
|
|
451
525
|
buildCommand
|
|
452
526
|
};
|
|
453
527
|
logger3.info("Application scanned", {
|
|
454
528
|
appName,
|
|
455
529
|
isSvelteKit,
|
|
456
530
|
adapterInstalled,
|
|
457
|
-
|
|
531
|
+
nodeAdapterInstalled,
|
|
532
|
+
requiredServices,
|
|
533
|
+
serviceBackends: configDetection.backends
|
|
458
534
|
});
|
|
459
535
|
return ok(scanResult);
|
|
460
536
|
} catch (error) {
|
|
@@ -472,7 +548,8 @@ function detectServicesFromDependencies(pkg) {
|
|
|
472
548
|
const depMap = {
|
|
473
549
|
"@h-ai/reldb": "db",
|
|
474
550
|
"@h-ai/cache": "cache",
|
|
475
|
-
"@h-ai/storage": "storage"
|
|
551
|
+
"@h-ai/storage": "storage",
|
|
552
|
+
"@h-ai/vecdb": "vecdb"
|
|
476
553
|
};
|
|
477
554
|
const services = [];
|
|
478
555
|
for (const [depName, serviceType] of Object.entries(depMap)) {
|
|
@@ -490,7 +567,667 @@ function extractAppName(pkg) {
|
|
|
490
567
|
}
|
|
491
568
|
return name;
|
|
492
569
|
}
|
|
493
|
-
var
|
|
570
|
+
var IMAGES = {
|
|
571
|
+
postgres: "postgres:16-alpine",
|
|
572
|
+
mysql: "mysql:8.4",
|
|
573
|
+
redis: "redis:7-alpine",
|
|
574
|
+
minio: "minio/minio:RELEASE.2025-09-07T16-13-09Z",
|
|
575
|
+
mc: "minio/mc:RELEASE.2025-08-13T08-35-41Z",
|
|
576
|
+
qdrant: "qdrant/qdrant:v1.12.4",
|
|
577
|
+
pgvector: "pgvector/pgvector:pg17",
|
|
578
|
+
chroma: "chromadb/chroma:0.5.23"
|
|
579
|
+
};
|
|
580
|
+
function generatePassword() {
|
|
581
|
+
return randomBytes(24).toString("base64url");
|
|
582
|
+
}
|
|
583
|
+
function resolveSidecarSecrets(appDir, projectName) {
|
|
584
|
+
const secretFile = join(appDir, ".hai", "deploy", "runtime", `${projectName}.secrets.json`);
|
|
585
|
+
if (existsSync(secretFile)) {
|
|
586
|
+
try {
|
|
587
|
+
return JSON.parse(readFileSync(secretFile, "utf-8"));
|
|
588
|
+
} catch {
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const secrets = {
|
|
592
|
+
dbPassword: generatePassword(),
|
|
593
|
+
storagePassword: generatePassword()
|
|
594
|
+
};
|
|
595
|
+
mkdirSync(dirname(secretFile), { recursive: true });
|
|
596
|
+
writeFileSync(secretFile, JSON.stringify(secrets), { mode: 384 });
|
|
597
|
+
return secrets;
|
|
598
|
+
}
|
|
599
|
+
function addAppDataVolume(plan, projectName) {
|
|
600
|
+
const mount = `${projectName}-data:/app/data`;
|
|
601
|
+
if (!plan.appVolumes.includes(mount)) {
|
|
602
|
+
plan.appVolumes.push(mount);
|
|
603
|
+
plan.volumes[`${projectName}-data`] = null;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
function planPostgresDb(plan, projectName, password, net) {
|
|
607
|
+
plan.services.push("db");
|
|
608
|
+
plan.dependsOn.push("db");
|
|
609
|
+
plan.composeServices.db = {
|
|
610
|
+
image: IMAGES.postgres,
|
|
611
|
+
container_name: `hai-${projectName}-db`,
|
|
612
|
+
restart: "unless-stopped",
|
|
613
|
+
env_file: [".env.runtime"],
|
|
614
|
+
volumes: [`${projectName}-db-data:/var/lib/postgresql/data`],
|
|
615
|
+
networks: net
|
|
616
|
+
};
|
|
617
|
+
plan.volumes[`${projectName}-db-data`] = null;
|
|
618
|
+
plan.env.POSTGRES_USER = "hai";
|
|
619
|
+
plan.env.POSTGRES_PASSWORD = password;
|
|
620
|
+
plan.env.POSTGRES_DB = "hai";
|
|
621
|
+
plan.env.HAI_DB = JSON.stringify({ type: "postgresql", url: `postgresql://hai:${password}@db:5432/hai`, database: "hai" });
|
|
622
|
+
plan.secrets.push(password);
|
|
623
|
+
}
|
|
624
|
+
function planMysqlDb(plan, projectName, password, net) {
|
|
625
|
+
plan.services.push("db");
|
|
626
|
+
plan.dependsOn.push("db");
|
|
627
|
+
plan.composeServices.db = {
|
|
628
|
+
image: IMAGES.mysql,
|
|
629
|
+
container_name: `hai-${projectName}-db`,
|
|
630
|
+
restart: "unless-stopped",
|
|
631
|
+
env_file: [".env.runtime"],
|
|
632
|
+
volumes: [`${projectName}-db-data:/var/lib/mysql`],
|
|
633
|
+
networks: net
|
|
634
|
+
};
|
|
635
|
+
plan.volumes[`${projectName}-db-data`] = null;
|
|
636
|
+
plan.env.MYSQL_ROOT_PASSWORD = password;
|
|
637
|
+
plan.env.MYSQL_DATABASE = "hai";
|
|
638
|
+
plan.env.MYSQL_USER = "hai";
|
|
639
|
+
plan.env.MYSQL_PASSWORD = password;
|
|
640
|
+
plan.env.HAI_DB = JSON.stringify({ type: "mysql", url: `mysql://hai:${password}@db:3306/hai`, database: "hai" });
|
|
641
|
+
plan.secrets.push(password);
|
|
642
|
+
}
|
|
643
|
+
function planRedisCache(plan, projectName, net) {
|
|
644
|
+
plan.services.push("cache");
|
|
645
|
+
plan.dependsOn.push("cache");
|
|
646
|
+
plan.composeServices.cache = {
|
|
647
|
+
image: IMAGES.redis,
|
|
648
|
+
container_name: `hai-${projectName}-cache`,
|
|
649
|
+
restart: "unless-stopped",
|
|
650
|
+
networks: net
|
|
651
|
+
};
|
|
652
|
+
plan.env.HAI_CACHE = JSON.stringify({ type: "redis", url: "redis://cache:6379" });
|
|
653
|
+
}
|
|
654
|
+
function planMinioStorage(plan, projectName, password, net) {
|
|
655
|
+
plan.services.push("storage");
|
|
656
|
+
plan.dependsOn.push("storage");
|
|
657
|
+
plan.composeServices.storage = {
|
|
658
|
+
image: IMAGES.minio,
|
|
659
|
+
container_name: `hai-${projectName}-storage`,
|
|
660
|
+
restart: "unless-stopped",
|
|
661
|
+
command: ["server", "/data"],
|
|
662
|
+
env_file: [".env.runtime"],
|
|
663
|
+
volumes: [`${projectName}-storage-data:/data`],
|
|
664
|
+
networks: net
|
|
665
|
+
};
|
|
666
|
+
plan.composeServices["storage-init"] = {
|
|
667
|
+
image: IMAGES.mc,
|
|
668
|
+
depends_on: ["storage"],
|
|
669
|
+
env_file: [".env.runtime"],
|
|
670
|
+
entrypoint: [
|
|
671
|
+
"sh",
|
|
672
|
+
"-c",
|
|
673
|
+
'until mc alias set s3 http://storage:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"; do sleep 1; done; mc mb -p s3/hai; exit 0'
|
|
674
|
+
],
|
|
675
|
+
networks: net
|
|
676
|
+
};
|
|
677
|
+
plan.volumes[`${projectName}-storage-data`] = null;
|
|
678
|
+
plan.env.MINIO_ROOT_USER = "hai";
|
|
679
|
+
plan.env.MINIO_ROOT_PASSWORD = password;
|
|
680
|
+
plan.env.HAI_STORAGE = JSON.stringify({
|
|
681
|
+
type: "s3",
|
|
682
|
+
endpoint: "http://storage:9000",
|
|
683
|
+
bucket: "hai",
|
|
684
|
+
region: "us-east-1",
|
|
685
|
+
accessKeyId: "hai",
|
|
686
|
+
secretAccessKey: password,
|
|
687
|
+
forcePathStyle: true
|
|
688
|
+
});
|
|
689
|
+
plan.secrets.push(password);
|
|
690
|
+
}
|
|
691
|
+
function planQdrantVecdb(plan, projectName, net) {
|
|
692
|
+
plan.services.push("vecdb");
|
|
693
|
+
plan.dependsOn.push("vecdb");
|
|
694
|
+
plan.composeServices.vecdb = {
|
|
695
|
+
image: IMAGES.qdrant,
|
|
696
|
+
container_name: `hai-${projectName}-vecdb`,
|
|
697
|
+
restart: "unless-stopped",
|
|
698
|
+
volumes: [`${projectName}-vecdb-data:/qdrant/storage`],
|
|
699
|
+
networks: net
|
|
700
|
+
};
|
|
701
|
+
plan.volumes[`${projectName}-vecdb-data`] = null;
|
|
702
|
+
plan.env.HAI_VECDB = JSON.stringify({ type: "qdrant", url: "http://vecdb:6333" });
|
|
703
|
+
}
|
|
704
|
+
function planPgvectorVecdb(plan, projectName, password, net) {
|
|
705
|
+
plan.services.push("vecdb");
|
|
706
|
+
plan.dependsOn.push("vecdb");
|
|
707
|
+
plan.composeServices.vecdb = {
|
|
708
|
+
image: IMAGES.pgvector,
|
|
709
|
+
container_name: `hai-${projectName}-vecdb`,
|
|
710
|
+
restart: "unless-stopped",
|
|
711
|
+
env_file: [".env.runtime"],
|
|
712
|
+
volumes: [`${projectName}-vecdb-data:/var/lib/postgresql/data`],
|
|
713
|
+
networks: net
|
|
714
|
+
};
|
|
715
|
+
plan.volumes[`${projectName}-vecdb-data`] = null;
|
|
716
|
+
plan.env.POSTGRES_USER = "hai";
|
|
717
|
+
plan.env.POSTGRES_PASSWORD = password;
|
|
718
|
+
plan.env.POSTGRES_DB = "hai";
|
|
719
|
+
plan.env.HAI_VECDB = JSON.stringify({ type: "pgvector", url: `postgresql://hai:${password}@vecdb:5432/hai`, database: "hai" });
|
|
720
|
+
plan.secrets.push(password);
|
|
721
|
+
}
|
|
722
|
+
function planChromaVecdb(plan, projectName, net) {
|
|
723
|
+
plan.services.push("vecdb");
|
|
724
|
+
plan.dependsOn.push("vecdb");
|
|
725
|
+
plan.composeServices.vecdb = {
|
|
726
|
+
image: IMAGES.chroma,
|
|
727
|
+
container_name: `hai-${projectName}-vecdb`,
|
|
728
|
+
restart: "unless-stopped",
|
|
729
|
+
volumes: [`${projectName}-vecdb-data:/data`],
|
|
730
|
+
networks: net
|
|
731
|
+
};
|
|
732
|
+
plan.volumes[`${projectName}-vecdb-data`] = null;
|
|
733
|
+
plan.env.HAI_VECDB = JSON.stringify({ type: "chroma", url: "http://vecdb:8000" });
|
|
734
|
+
}
|
|
735
|
+
function planSidecars(appDir, projectName, scan, provisionEnv) {
|
|
736
|
+
const plan = {
|
|
737
|
+
services: [],
|
|
738
|
+
composeServices: {},
|
|
739
|
+
volumes: {},
|
|
740
|
+
appVolumes: [],
|
|
741
|
+
env: {},
|
|
742
|
+
dependsOn: [],
|
|
743
|
+
secrets: []
|
|
744
|
+
};
|
|
745
|
+
const secrets = resolveSidecarSecrets(appDir, projectName);
|
|
746
|
+
const net = ["hai-app"];
|
|
747
|
+
const backends = scan.serviceBackends;
|
|
748
|
+
for (const service of scan.requiredServices) {
|
|
749
|
+
if (service === "db" && !provisionEnv.HAI_DB) {
|
|
750
|
+
const backend = backends.db ?? "postgresql";
|
|
751
|
+
if (backend === "mysql")
|
|
752
|
+
planMysqlDb(plan, projectName, secrets.dbPassword, net);
|
|
753
|
+
else if (backend === "sqlite")
|
|
754
|
+
addAppDataVolume(plan, projectName);
|
|
755
|
+
else
|
|
756
|
+
planPostgresDb(plan, projectName, secrets.dbPassword, net);
|
|
757
|
+
} else if (service === "cache" && !provisionEnv.HAI_CACHE) {
|
|
758
|
+
planRedisCache(plan, projectName, net);
|
|
759
|
+
} else if (service === "storage" && !provisionEnv.HAI_STORAGE) {
|
|
760
|
+
planMinioStorage(plan, projectName, secrets.storagePassword, net);
|
|
761
|
+
} else if (service === "vecdb" && !provisionEnv.HAI_VECDB) {
|
|
762
|
+
const backend = backends.vecdb ?? "qdrant";
|
|
763
|
+
if (backend === "pgvector")
|
|
764
|
+
planPgvectorVecdb(plan, projectName, secrets.dbPassword, net);
|
|
765
|
+
else if (backend === "chroma")
|
|
766
|
+
planChromaVecdb(plan, projectName, net);
|
|
767
|
+
else if (backend === "lancedb")
|
|
768
|
+
addAppDataVolume(plan, projectName);
|
|
769
|
+
else
|
|
770
|
+
planQdrantVecdb(plan, projectName, net);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return plan;
|
|
774
|
+
}
|
|
775
|
+
function buildRuntimeEnv(appDir, projectName, scan, provisionEnv, containerPort) {
|
|
776
|
+
const sidecar = planSidecars(appDir, projectName, scan, provisionEnv);
|
|
777
|
+
const env = {
|
|
778
|
+
NODE_ENV: "production",
|
|
779
|
+
HOST: "0.0.0.0",
|
|
780
|
+
PORT: String(containerPort),
|
|
781
|
+
...sidecar.env,
|
|
782
|
+
...provisionEnv
|
|
783
|
+
};
|
|
784
|
+
return { env, sidecar };
|
|
785
|
+
}
|
|
786
|
+
function generateComposeFile(options) {
|
|
787
|
+
const { projectName, imageRef, hostPort, containerPort, sidecar } = options;
|
|
788
|
+
const app = {
|
|
789
|
+
image: imageRef,
|
|
790
|
+
container_name: `hai-${projectName}-app`,
|
|
791
|
+
restart: "unless-stopped",
|
|
792
|
+
// 镜像已由 load 注入本地,禁止 compose 再从 registry 拉取
|
|
793
|
+
pull_policy: "never",
|
|
794
|
+
env_file: [".env.runtime"],
|
|
795
|
+
ports: [`${hostPort}:${containerPort}`],
|
|
796
|
+
networks: ["hai-app"]
|
|
797
|
+
};
|
|
798
|
+
if (sidecar.appVolumes.length > 0)
|
|
799
|
+
app.volumes = sidecar.appVolumes;
|
|
800
|
+
if (sidecar.dependsOn.length > 0)
|
|
801
|
+
app.depends_on = sidecar.dependsOn;
|
|
802
|
+
const compose = {
|
|
803
|
+
services: {
|
|
804
|
+
app,
|
|
805
|
+
...sidecar.composeServices
|
|
806
|
+
},
|
|
807
|
+
networks: {
|
|
808
|
+
"hai-app": { name: `hai-${projectName}` }
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
if (Object.keys(sidecar.volumes).length > 0)
|
|
812
|
+
compose.volumes = sidecar.volumes;
|
|
813
|
+
return stringify(compose);
|
|
814
|
+
}
|
|
815
|
+
function serializeEnvFile(env) {
|
|
816
|
+
return `${Object.entries(env).map(([key, value]) => `${key}=${value}`).join("\n")}
|
|
817
|
+
`;
|
|
818
|
+
}
|
|
819
|
+
function generateDockerignore() {
|
|
820
|
+
return `**/node_modules
|
|
821
|
+
**/dist
|
|
822
|
+
**/build
|
|
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:22-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@9.15.0 --activate
|
|
840
|
+
COPY . .
|
|
841
|
+
RUN pnpm install --frozen-lockfile --ignore-scripts
|
|
842
|
+
RUN pnpm --filter ${packageName} build
|
|
843
|
+
RUN pnpm --filter ${packageName} --prod deploy /prod/app
|
|
844
|
+
|
|
845
|
+
FROM node:22-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;
|
|
917
|
+
}
|
|
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
|
+
}
|
|
949
|
+
}
|
|
950
|
+
return err(
|
|
951
|
+
HaiDeployError.CONTAINER_RUNTIME_NOT_FOUND,
|
|
952
|
+
deployM("deploy_containerRuntimeNotFound", { params: { runtime: candidates.join(" / ") } })
|
|
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
|
+
);
|
|
968
|
+
}
|
|
969
|
+
return ok(void 0);
|
|
970
|
+
}
|
|
971
|
+
async function saveImage(runtime, imageRef, tarPath) {
|
|
972
|
+
logger5.info("Saving container image", { runtime, imageRef, tarPath });
|
|
973
|
+
const result = await runCommand(runtime, ["save", "-o", tarPath, imageRef], {});
|
|
974
|
+
if (!result.success) {
|
|
975
|
+
return err(
|
|
976
|
+
HaiDeployError.CONTAINER_BUILD_FAILED,
|
|
977
|
+
deployM("deploy_containerBuildFailed", { params: { error: result.error.message } }),
|
|
978
|
+
result.error
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
return ok(void 0);
|
|
982
|
+
}
|
|
983
|
+
var logger6 = core.logger.child({ module: "deploy", scope: "ssh" });
|
|
984
|
+
function buildConnectOptions(config) {
|
|
985
|
+
const args = ["-o", "BatchMode=yes"];
|
|
986
|
+
args.push("-o", `ConnectTimeout=${config.connectTimeoutSeconds}`);
|
|
987
|
+
args.push("-o", `StrictHostKeyChecking=${config.strictHostKeyChecking ? "accept-new" : "no"}`);
|
|
988
|
+
if (config.knownHostsFile)
|
|
989
|
+
args.push("-o", `UserKnownHostsFile=${config.knownHostsFile}`);
|
|
990
|
+
if (config.identityFile) {
|
|
991
|
+
args.push("-i", config.identityFile);
|
|
992
|
+
args.push("-o", "IdentitiesOnly=yes");
|
|
993
|
+
}
|
|
994
|
+
return args;
|
|
995
|
+
}
|
|
996
|
+
function buildSshExecArgs(config, remoteCommand) {
|
|
997
|
+
return [
|
|
998
|
+
...buildConnectOptions(config),
|
|
999
|
+
"-p",
|
|
1000
|
+
String(config.port),
|
|
1001
|
+
`${config.username}@${config.host}`,
|
|
1002
|
+
remoteCommand
|
|
1003
|
+
];
|
|
1004
|
+
}
|
|
1005
|
+
function buildScpUploadArgs(config, localPath, remotePath) {
|
|
1006
|
+
return [
|
|
1007
|
+
...buildConnectOptions(config),
|
|
1008
|
+
"-P",
|
|
1009
|
+
String(config.port),
|
|
1010
|
+
localPath,
|
|
1011
|
+
`${config.username}@${config.host}:${remotePath}`
|
|
1012
|
+
];
|
|
1013
|
+
}
|
|
1014
|
+
function createSshExecutor(config) {
|
|
1015
|
+
const exec = async (remoteCommand, options) => {
|
|
1016
|
+
return runCommand("ssh", buildSshExecArgs(config, remoteCommand), {
|
|
1017
|
+
timeoutMs: options?.timeoutMs,
|
|
1018
|
+
secrets: options?.secrets
|
|
1019
|
+
});
|
|
1020
|
+
};
|
|
1021
|
+
const upload = async (localPath, remotePath) => {
|
|
1022
|
+
const args = buildScpUploadArgs(config, localPath, remotePath);
|
|
1023
|
+
const result = await runCommand("scp", args, {});
|
|
1024
|
+
if (!result.success) {
|
|
1025
|
+
return err(
|
|
1026
|
+
HaiDeployError.IMAGE_TRANSFER_FAILED,
|
|
1027
|
+
deployM("deploy_imageTransferFailed", { params: { error: result.error.message } }),
|
|
1028
|
+
result.error
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
return ok(void 0);
|
|
1032
|
+
};
|
|
1033
|
+
const ping = async () => {
|
|
1034
|
+
logger6.debug("SSH preflight", { host: config.host, port: config.port });
|
|
1035
|
+
const result = await exec("true", { timeoutMs: 2e4 });
|
|
1036
|
+
if (!result.success) {
|
|
1037
|
+
return err(
|
|
1038
|
+
HaiDeployError.REMOTE_CONNECT_FAILED,
|
|
1039
|
+
deployM("deploy_remoteConnectFailed", { params: { host: config.host, error: result.error.message } }),
|
|
1040
|
+
result.error
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
return ok(void 0);
|
|
1044
|
+
};
|
|
1045
|
+
return { exec, upload, ping };
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// src/providers/deploy-provider-docker-ssh.ts
|
|
1049
|
+
var logger7 = core.logger.child({ module: "deploy", scope: "provider-docker-ssh" });
|
|
1050
|
+
var HEALTH_POLL_INTERVAL = 3e3;
|
|
1051
|
+
var HEALTH_MAX_ATTEMPTS = 40;
|
|
1052
|
+
function makeDeploymentId() {
|
|
1053
|
+
const now = /* @__PURE__ */ new Date();
|
|
1054
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
1055
|
+
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
1056
|
+
return `${stamp}-${randomBytes(3).toString("hex")}`;
|
|
1057
|
+
}
|
|
1058
|
+
function parseLoadedImageRef(stdout, fallback) {
|
|
1059
|
+
const match = stdout.match(/Loaded image(?:\(s\))?:\s*(\S+)/);
|
|
1060
|
+
return match ? match[1] : fallback;
|
|
1061
|
+
}
|
|
1062
|
+
function sleep(ms) {
|
|
1063
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1064
|
+
}
|
|
1065
|
+
function readPackageName(appDir) {
|
|
1066
|
+
try {
|
|
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);
|
|
1088
|
+
const baseDir = config.remote.baseDir;
|
|
1089
|
+
async function detectRemoteRuntime() {
|
|
1090
|
+
const candidates = config.remote.runtime === "auto" ? ["podman", "docker"] : [config.remote.runtime];
|
|
1091
|
+
for (const runtime of candidates) {
|
|
1092
|
+
const versionResult = await ssh.exec(`${runtime} version`, { timeoutMs: 2e4 });
|
|
1093
|
+
if (!versionResult.success)
|
|
1094
|
+
continue;
|
|
1095
|
+
const composeResult = await ssh.exec(`${runtime} compose version`, { timeoutMs: 2e4 });
|
|
1096
|
+
if (composeResult.success) {
|
|
1097
|
+
logger7.info("Remote container runtime detected", { runtime });
|
|
1098
|
+
return ok(runtime);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
return err(
|
|
1102
|
+
HaiDeployError.REMOTE_COMMAND_FAILED,
|
|
1103
|
+
deployM("deploy_remoteRuntimeMissing", { params: { runtime: candidates.join(" / ") } })
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
async function healthCheck(url) {
|
|
1107
|
+
for (let attempt = 0; attempt < HEALTH_MAX_ATTEMPTS; attempt++) {
|
|
1108
|
+
try {
|
|
1109
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(5e3) });
|
|
1110
|
+
if (response.ok)
|
|
1111
|
+
return true;
|
|
1112
|
+
} catch {
|
|
1113
|
+
}
|
|
1114
|
+
await sleep(HEALTH_POLL_INTERVAL);
|
|
1115
|
+
}
|
|
1116
|
+
return false;
|
|
1117
|
+
}
|
|
1118
|
+
const deploy2 = async (context) => {
|
|
1119
|
+
const { appDir, projectName, scan, provisionEnv } = context;
|
|
1120
|
+
const deploymentId = makeDeploymentId();
|
|
1121
|
+
const containerPort = config.expose.containerPort;
|
|
1122
|
+
const hostPort = config.expose.hostPort;
|
|
1123
|
+
const localImageTag = `hai-${projectName}:${deploymentId}`;
|
|
1124
|
+
const runtimeResult = await detectLocalRuntime(localRuntimePref);
|
|
1125
|
+
if (!runtimeResult.success)
|
|
1126
|
+
return err(runtimeResult.error);
|
|
1127
|
+
const localRuntime = runtimeResult.data;
|
|
1128
|
+
const artifactDir = join(appDir, ".hai", "deploy");
|
|
1129
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
1130
|
+
const appDockerfile = join(appDir, "Dockerfile");
|
|
1131
|
+
let dockerfilePath;
|
|
1132
|
+
let contextDir;
|
|
1133
|
+
if (existsSync(appDockerfile)) {
|
|
1134
|
+
dockerfilePath = appDockerfile;
|
|
1135
|
+
contextDir = appDir;
|
|
1136
|
+
} else {
|
|
1137
|
+
dockerfilePath = join(artifactDir, "Dockerfile");
|
|
1138
|
+
writeFileSync(dockerfilePath, generateFallbackDockerfile(readPackageName(appDir), containerPort));
|
|
1139
|
+
writeFileSync(`${dockerfilePath}.dockerignore`, generateDockerignore());
|
|
1140
|
+
contextDir = resolveWorkspaceRoot(appDir);
|
|
1141
|
+
logger7.info("Generated fallback Dockerfile", { dockerfilePath, contextDir });
|
|
1142
|
+
}
|
|
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
|
+
const envPath = join(artifactDir, ".env.runtime");
|
|
1152
|
+
writeFileSync(envPath, serializeEnvFile(env), { mode: 384 });
|
|
1153
|
+
const tarPath = join(artifactDir, `${projectName}.tar`);
|
|
1154
|
+
const saveResult = await saveImage(localRuntime, localImageTag, tarPath);
|
|
1155
|
+
if (!saveResult.success)
|
|
1156
|
+
return err(saveResult.error);
|
|
1157
|
+
const pingResult = await ssh.ping();
|
|
1158
|
+
if (!pingResult.success)
|
|
1159
|
+
return err(pingResult.error);
|
|
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);
|
|
1181
|
+
}
|
|
1182
|
+
const composeContent = generateComposeFile({
|
|
1183
|
+
projectName,
|
|
1184
|
+
imageRef: remoteImageRef,
|
|
1185
|
+
hostPort,
|
|
1186
|
+
containerPort,
|
|
1187
|
+
sidecar
|
|
1188
|
+
});
|
|
1189
|
+
const composePath = join(artifactDir, "compose.yml");
|
|
1190
|
+
writeFileSync(composePath, composeContent);
|
|
1191
|
+
const uploadComposeResult = await ssh.upload(composePath, `${remoteDir}/compose.yml`);
|
|
1192
|
+
if (!uploadComposeResult.success)
|
|
1193
|
+
return err(uploadComposeResult.error);
|
|
1194
|
+
const uploadEnvResult = await ssh.upload(envPath, `${remoteDir}/.env.runtime`);
|
|
1195
|
+
if (!uploadEnvResult.success)
|
|
1196
|
+
return err(uploadEnvResult.error);
|
|
1197
|
+
const secretMask = sidecar.secrets;
|
|
1198
|
+
const chmodResult = await ssh.exec(`chmod 600 ${remoteDir}/.env.runtime`, { secrets: secretMask });
|
|
1199
|
+
if (!chmodResult.success)
|
|
1200
|
+
return err(chmodResult.error);
|
|
1201
|
+
const upResult = await ssh.exec(
|
|
1202
|
+
`cd ${remoteDir} && ${remoteRuntime} compose -p hai-${projectName} up -d`,
|
|
1203
|
+
{ timeoutMs: 3e5, secrets: secretMask }
|
|
1204
|
+
);
|
|
1205
|
+
if (!upResult.success)
|
|
1206
|
+
return err(upResult.error);
|
|
1207
|
+
await ssh.exec(`rm -f ${remoteTar}`);
|
|
1208
|
+
const url = `http://${config.ssh.host}:${hostPort}`;
|
|
1209
|
+
logger7.info("Running health check", { url });
|
|
1210
|
+
const healthy = await healthCheck(`${url}/`);
|
|
1211
|
+
if (!healthy) {
|
|
1212
|
+
return err(
|
|
1213
|
+
HaiDeployError.HEALTH_CHECK_FAILED,
|
|
1214
|
+
deployM("deploy_healthCheckFailed", { params: { url } })
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
const result = {
|
|
1218
|
+
url,
|
|
1219
|
+
deploymentId,
|
|
1220
|
+
status: "ready",
|
|
1221
|
+
provider: "docker-ssh",
|
|
1222
|
+
healthCheck: "passed",
|
|
1223
|
+
envVarsSet: Object.keys(env)
|
|
1224
|
+
};
|
|
1225
|
+
logger7.info("docker-ssh deployment complete", { url, deploymentId });
|
|
1226
|
+
return ok(result);
|
|
1227
|
+
};
|
|
1228
|
+
return { name: "docker-ssh", deploy: deploy2 };
|
|
1229
|
+
}
|
|
1230
|
+
var logger8 = core.logger.child({ module: "deploy", scope: "provider-vercel" });
|
|
494
1231
|
var VERCEL_API = "https://api.vercel.com";
|
|
495
1232
|
var POLL_INTERVAL = 3e3;
|
|
496
1233
|
var MAX_POLL_ATTEMPTS = 60;
|
|
@@ -542,7 +1279,7 @@ function createVercelProvider() {
|
|
|
542
1279
|
return {
|
|
543
1280
|
name: "vercel",
|
|
544
1281
|
async authenticate(apiToken) {
|
|
545
|
-
|
|
1282
|
+
logger8.debug("Authenticating with Vercel");
|
|
546
1283
|
try {
|
|
547
1284
|
const user = await vercelFetch(
|
|
548
1285
|
apiToken,
|
|
@@ -550,10 +1287,10 @@ function createVercelProvider() {
|
|
|
550
1287
|
);
|
|
551
1288
|
token = apiToken;
|
|
552
1289
|
const username = user.user.username || user.user.email;
|
|
553
|
-
|
|
1290
|
+
logger8.info("Vercel authenticated", { user: username });
|
|
554
1291
|
return ok(username);
|
|
555
1292
|
} catch (error) {
|
|
556
|
-
|
|
1293
|
+
logger8.error("Vercel authentication failed", { error });
|
|
557
1294
|
return err(toDeployError(HaiDeployError.AUTH_FAILED, "deploy_authFailed", error));
|
|
558
1295
|
}
|
|
559
1296
|
},
|
|
@@ -564,11 +1301,11 @@ function createVercelProvider() {
|
|
|
564
1301
|
deployM("deploy_authRequired")
|
|
565
1302
|
);
|
|
566
1303
|
}
|
|
567
|
-
|
|
1304
|
+
logger8.debug("Creating Vercel project", { projectName });
|
|
568
1305
|
try {
|
|
569
1306
|
const existingId = await findExistingProject(token, projectName);
|
|
570
1307
|
if (existingId !== null) {
|
|
571
|
-
|
|
1308
|
+
logger8.info("Vercel project found", { projectName, projectId: existingId });
|
|
572
1309
|
return ok(existingId);
|
|
573
1310
|
}
|
|
574
1311
|
try {
|
|
@@ -583,13 +1320,13 @@ function createVercelProvider() {
|
|
|
583
1320
|
})
|
|
584
1321
|
}
|
|
585
1322
|
);
|
|
586
|
-
|
|
1323
|
+
logger8.info("Vercel project created", { projectName, projectId: project.id });
|
|
587
1324
|
return ok(project.id);
|
|
588
1325
|
} catch (error) {
|
|
589
1326
|
if (error instanceof VercelApiError && error.status === 409) {
|
|
590
1327
|
const concurrentProjectId = await findExistingProject(token, projectName);
|
|
591
1328
|
if (concurrentProjectId !== null) {
|
|
592
|
-
|
|
1329
|
+
logger8.info("Vercel project reused after create conflict", {
|
|
593
1330
|
projectName,
|
|
594
1331
|
projectId: concurrentProjectId
|
|
595
1332
|
});
|
|
@@ -599,7 +1336,7 @@ function createVercelProvider() {
|
|
|
599
1336
|
throw error;
|
|
600
1337
|
}
|
|
601
1338
|
} catch (error) {
|
|
602
|
-
|
|
1339
|
+
logger8.error("Vercel project creation failed", { projectName, error });
|
|
603
1340
|
return err(toDeployError(HaiDeployError.PROJECT_CREATE_FAILED, "deploy_projectCreateFailed", error));
|
|
604
1341
|
}
|
|
605
1342
|
},
|
|
@@ -610,7 +1347,7 @@ function createVercelProvider() {
|
|
|
610
1347
|
deployM("deploy_authRequired")
|
|
611
1348
|
);
|
|
612
1349
|
}
|
|
613
|
-
|
|
1350
|
+
logger8.debug("Setting Vercel env vars", { projectId, count: Object.keys(envVars).length });
|
|
614
1351
|
try {
|
|
615
1352
|
const envEntries = Object.entries(envVars).map(([key, value]) => ({
|
|
616
1353
|
key,
|
|
@@ -626,10 +1363,10 @@ function createVercelProvider() {
|
|
|
626
1363
|
body: JSON.stringify(envEntries)
|
|
627
1364
|
}
|
|
628
1365
|
);
|
|
629
|
-
|
|
1366
|
+
logger8.info("Vercel env vars set", { projectId, count: envEntries.length });
|
|
630
1367
|
return ok(void 0);
|
|
631
1368
|
} catch (error) {
|
|
632
|
-
|
|
1369
|
+
logger8.error("Vercel env vars setup failed", { projectId, error });
|
|
633
1370
|
return err(toDeployError(HaiDeployError.ENV_VAR_FAILED, "deploy_envVarFailed", error));
|
|
634
1371
|
}
|
|
635
1372
|
},
|
|
@@ -640,7 +1377,7 @@ function createVercelProvider() {
|
|
|
640
1377
|
deployM("deploy_authRequired")
|
|
641
1378
|
);
|
|
642
1379
|
}
|
|
643
|
-
|
|
1380
|
+
logger8.debug("Deploying to Vercel", { projectId, outputDir });
|
|
644
1381
|
try {
|
|
645
1382
|
const filePaths = collectFiles(outputDir, outputDir);
|
|
646
1383
|
const files = [];
|
|
@@ -652,7 +1389,7 @@ function createVercelProvider() {
|
|
|
652
1389
|
await uploadFile(token, content, sha);
|
|
653
1390
|
files.push({ file: filePath, sha, size });
|
|
654
1391
|
}
|
|
655
|
-
|
|
1392
|
+
logger8.debug("Files uploaded", { count: files.length });
|
|
656
1393
|
const deployment = await vercelFetch(
|
|
657
1394
|
token,
|
|
658
1395
|
"/v13/deployments",
|
|
@@ -672,14 +1409,14 @@ function createVercelProvider() {
|
|
|
672
1409
|
status: finalStatus,
|
|
673
1410
|
envVarsSet: []
|
|
674
1411
|
};
|
|
675
|
-
|
|
1412
|
+
logger8.info("Vercel deployment complete", {
|
|
676
1413
|
deploymentId: deployment.id,
|
|
677
1414
|
url: result.url,
|
|
678
1415
|
status: finalStatus
|
|
679
1416
|
});
|
|
680
1417
|
return ok(result);
|
|
681
1418
|
} catch (error) {
|
|
682
|
-
|
|
1419
|
+
logger8.error("Vercel deployment failed", { projectId, error });
|
|
683
1420
|
return err(toDeployError(HaiDeployError.UPLOAD_FAILED, "deploy_uploadFailed", error));
|
|
684
1421
|
}
|
|
685
1422
|
}
|
|
@@ -728,7 +1465,7 @@ async function pollDeploymentStatus(token, deploymentId) {
|
|
|
728
1465
|
}
|
|
729
1466
|
return "building";
|
|
730
1467
|
}
|
|
731
|
-
var
|
|
1468
|
+
var logger9 = core.logger.child({ module: "deploy", scope: "provisioner-aliyun" });
|
|
732
1469
|
function createAliyunProvisioner() {
|
|
733
1470
|
let accessKey = null;
|
|
734
1471
|
let secretKey = null;
|
|
@@ -737,7 +1474,7 @@ function createAliyunProvisioner() {
|
|
|
737
1474
|
name: "aliyun",
|
|
738
1475
|
serviceType: "sms",
|
|
739
1476
|
async authenticate(credentials) {
|
|
740
|
-
|
|
1477
|
+
logger9.debug("Authenticating with Aliyun");
|
|
741
1478
|
try {
|
|
742
1479
|
const akId = credentials.accessKeyId ?? credentials.access_key_id ?? credentials.access_key ?? "";
|
|
743
1480
|
const akSecret = credentials.accessKeySecret ?? credentials.access_key_secret ?? credentials.secret_key ?? "";
|
|
@@ -747,10 +1484,10 @@ function createAliyunProvisioner() {
|
|
|
747
1484
|
accessKey = akId;
|
|
748
1485
|
secretKey = akSecret;
|
|
749
1486
|
signName = credentials.signName;
|
|
750
|
-
|
|
1487
|
+
logger9.info("Aliyun authenticated", { accessKeyId: `${akId.slice(0, 6)}***` });
|
|
751
1488
|
return ok(akId);
|
|
752
1489
|
} catch (error) {
|
|
753
|
-
|
|
1490
|
+
logger9.error("Aliyun authentication failed", { error });
|
|
754
1491
|
return err(
|
|
755
1492
|
HaiDeployError.AUTH_FAILED,
|
|
756
1493
|
deployM("deploy_authFailed", {
|
|
@@ -767,7 +1504,7 @@ function createAliyunProvisioner() {
|
|
|
767
1504
|
deployM("deploy_authRequired")
|
|
768
1505
|
);
|
|
769
1506
|
}
|
|
770
|
-
|
|
1507
|
+
logger9.debug("Provisioning Aliyun SMS service");
|
|
771
1508
|
return ok({
|
|
772
1509
|
serviceType: "sms",
|
|
773
1510
|
provisionerName: "aliyun",
|
|
@@ -780,7 +1517,7 @@ function createAliyunProvisioner() {
|
|
|
780
1517
|
}
|
|
781
1518
|
};
|
|
782
1519
|
}
|
|
783
|
-
var
|
|
1520
|
+
var logger10 = core.logger.child({ module: "deploy", scope: "provisioner-neon" });
|
|
784
1521
|
var NEON_API = "https://console.neon.tech/api/v2";
|
|
785
1522
|
var DEFAULT_NEON_DATABASE_NAME = "neondb";
|
|
786
1523
|
var DEFAULT_NEON_ROLE_NAME = "neondb_owner";
|
|
@@ -839,7 +1576,7 @@ function createNeonProvisioner() {
|
|
|
839
1576
|
name: "neon",
|
|
840
1577
|
serviceType: "db",
|
|
841
1578
|
async authenticate(credentials) {
|
|
842
|
-
|
|
1579
|
+
logger10.debug("Authenticating with Neon");
|
|
843
1580
|
try {
|
|
844
1581
|
const apiToken = credentials.apiKey ?? credentials.token ?? credentials.api_key ?? "";
|
|
845
1582
|
if (!apiToken) {
|
|
@@ -847,10 +1584,10 @@ function createNeonProvisioner() {
|
|
|
847
1584
|
}
|
|
848
1585
|
await listNeonProjects(apiToken);
|
|
849
1586
|
token = apiToken;
|
|
850
|
-
|
|
1587
|
+
logger10.info("Neon authenticated");
|
|
851
1588
|
return ok("neon-user");
|
|
852
1589
|
} catch (error) {
|
|
853
|
-
|
|
1590
|
+
logger10.error("Neon authentication failed", { error });
|
|
854
1591
|
return err(
|
|
855
1592
|
HaiDeployError.AUTH_FAILED,
|
|
856
1593
|
deployM("deploy_authFailed", {
|
|
@@ -867,7 +1604,7 @@ function createNeonProvisioner() {
|
|
|
867
1604
|
deployM("deploy_authRequired")
|
|
868
1605
|
);
|
|
869
1606
|
}
|
|
870
|
-
|
|
1607
|
+
logger10.debug("Provisioning Neon database", { appName });
|
|
871
1608
|
let resource;
|
|
872
1609
|
try {
|
|
873
1610
|
const projectName = `${appName}-db`;
|
|
@@ -875,7 +1612,7 @@ function createNeonProvisioner() {
|
|
|
875
1612
|
if (existingProject !== null) {
|
|
876
1613
|
resource = { resourceInfo: `neon-project:${existingProject.id}`, resourceStatus: "reused" };
|
|
877
1614
|
const connectionUri2 = await getNeonConnectionUri(token, existingProject.id);
|
|
878
|
-
|
|
1615
|
+
logger10.info("Neon project reused", {
|
|
879
1616
|
projectId: existingProject.id,
|
|
880
1617
|
projectName
|
|
881
1618
|
});
|
|
@@ -897,10 +1634,10 @@ function createNeonProvisioner() {
|
|
|
897
1634
|
if (!projectId || !connectionUri) {
|
|
898
1635
|
throw new Error(deployM("deploy_provisionNoResult", { params: { service: "Neon" } }));
|
|
899
1636
|
}
|
|
900
|
-
|
|
1637
|
+
logger10.info("Neon database provisioned", { projectId });
|
|
901
1638
|
return ok(buildNeonProvisionResult(projectId, connectionUri, "created"));
|
|
902
1639
|
} catch (error) {
|
|
903
|
-
|
|
1640
|
+
logger10.error("Neon provisioning failed", { appName });
|
|
904
1641
|
if (resource) {
|
|
905
1642
|
return recoveryFailure({ ...HaiDeployError.PROVISION_FAILED}, {
|
|
906
1643
|
projectName: appName,
|
|
@@ -922,7 +1659,7 @@ function createNeonProvisioner() {
|
|
|
922
1659
|
}
|
|
923
1660
|
};
|
|
924
1661
|
}
|
|
925
|
-
var
|
|
1662
|
+
var logger11 = core.logger.child({ module: "deploy", scope: "provisioner-r2" });
|
|
926
1663
|
var CF_API = "https://api.cloudflare.com/client/v4";
|
|
927
1664
|
function createR2Provisioner() {
|
|
928
1665
|
let token = null;
|
|
@@ -933,7 +1670,7 @@ function createR2Provisioner() {
|
|
|
933
1670
|
name: "r2",
|
|
934
1671
|
serviceType: "storage",
|
|
935
1672
|
async authenticate(credentials) {
|
|
936
|
-
|
|
1673
|
+
logger11.debug("Authenticating with Cloudflare");
|
|
937
1674
|
try {
|
|
938
1675
|
const acctId = credentials.accountId ?? credentials.account_id ?? "";
|
|
939
1676
|
const apiTok = credentials.apiToken ?? credentials.api_token ?? credentials.token ?? "";
|
|
@@ -950,10 +1687,10 @@ function createR2Provisioner() {
|
|
|
950
1687
|
token = apiTok;
|
|
951
1688
|
accessKey = credentials.accessKeyId;
|
|
952
1689
|
secretKey = credentials.secretAccessKey;
|
|
953
|
-
|
|
1690
|
+
logger11.info("Cloudflare R2 authenticated", { accountId: acctId });
|
|
954
1691
|
return ok(acctId);
|
|
955
1692
|
} catch (error) {
|
|
956
|
-
|
|
1693
|
+
logger11.error("Cloudflare R2 authentication failed", { error });
|
|
957
1694
|
return err(
|
|
958
1695
|
HaiDeployError.AUTH_FAILED,
|
|
959
1696
|
deployM("deploy_authFailed", {
|
|
@@ -971,7 +1708,7 @@ function createR2Provisioner() {
|
|
|
971
1708
|
);
|
|
972
1709
|
}
|
|
973
1710
|
const bucketName = `${appName}-storage`.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
974
|
-
|
|
1711
|
+
logger11.debug("Provisioning R2 bucket", { appName, bucketName });
|
|
975
1712
|
try {
|
|
976
1713
|
const createRes = await fetch(
|
|
977
1714
|
`${CF_API}/accounts/${accountId}/r2/buckets`,
|
|
@@ -987,7 +1724,7 @@ function createR2Provisioner() {
|
|
|
987
1724
|
if (!createRes.ok && createRes.status !== 409) {
|
|
988
1725
|
throw new Error(deployM("deploy_apiError", { params: { service: "Cloudflare", status: String(createRes.status) } }));
|
|
989
1726
|
}
|
|
990
|
-
|
|
1727
|
+
logger11.info("R2 bucket provisioned", { bucketName });
|
|
991
1728
|
return ok({
|
|
992
1729
|
serviceType: "storage",
|
|
993
1730
|
provisionerName: "r2",
|
|
@@ -1005,7 +1742,7 @@ function createR2Provisioner() {
|
|
|
1005
1742
|
resourceInfo: `r2-bucket:${bucketName}`
|
|
1006
1743
|
});
|
|
1007
1744
|
} catch (error) {
|
|
1008
|
-
|
|
1745
|
+
logger11.error("R2 provisioning failed", { appName, error });
|
|
1009
1746
|
return err(
|
|
1010
1747
|
HaiDeployError.PROVISION_FAILED,
|
|
1011
1748
|
deployM("deploy_provisionFailed", {
|
|
@@ -1020,7 +1757,7 @@ function createR2Provisioner() {
|
|
|
1020
1757
|
}
|
|
1021
1758
|
};
|
|
1022
1759
|
}
|
|
1023
|
-
var
|
|
1760
|
+
var logger12 = core.logger.child({ module: "deploy", scope: "provisioner-resend" });
|
|
1024
1761
|
var RESEND_API = "https://api.resend.com";
|
|
1025
1762
|
function createResendProvisioner() {
|
|
1026
1763
|
let token = null;
|
|
@@ -1029,7 +1766,7 @@ function createResendProvisioner() {
|
|
|
1029
1766
|
name: "resend",
|
|
1030
1767
|
serviceType: "email",
|
|
1031
1768
|
async authenticate(credentials) {
|
|
1032
|
-
|
|
1769
|
+
logger12.debug("Authenticating with Resend");
|
|
1033
1770
|
try {
|
|
1034
1771
|
const apiToken = credentials.apiKey ?? credentials.api_key ?? credentials.token ?? "";
|
|
1035
1772
|
if (!apiToken || !credentials.from) {
|
|
@@ -1043,10 +1780,10 @@ function createResendProvisioner() {
|
|
|
1043
1780
|
}
|
|
1044
1781
|
token = apiToken;
|
|
1045
1782
|
from = credentials.from;
|
|
1046
|
-
|
|
1783
|
+
logger12.info("Resend authenticated");
|
|
1047
1784
|
return ok("resend-user");
|
|
1048
1785
|
} catch (error) {
|
|
1049
|
-
|
|
1786
|
+
logger12.error("Resend authentication failed", { error });
|
|
1050
1787
|
return err(
|
|
1051
1788
|
HaiDeployError.AUTH_FAILED,
|
|
1052
1789
|
deployM("deploy_authFailed", {
|
|
@@ -1063,7 +1800,7 @@ function createResendProvisioner() {
|
|
|
1063
1800
|
deployM("deploy_authRequired")
|
|
1064
1801
|
);
|
|
1065
1802
|
}
|
|
1066
|
-
|
|
1803
|
+
logger12.debug("Provisioning Resend email service");
|
|
1067
1804
|
return ok({
|
|
1068
1805
|
serviceType: "email",
|
|
1069
1806
|
provisionerName: "resend",
|
|
@@ -1076,7 +1813,7 @@ function createResendProvisioner() {
|
|
|
1076
1813
|
}
|
|
1077
1814
|
};
|
|
1078
1815
|
}
|
|
1079
|
-
var
|
|
1816
|
+
var logger13 = core.logger.child({ module: "deploy", scope: "provisioner-upstash" });
|
|
1080
1817
|
var UPSTASH_API = "https://api.upstash.com";
|
|
1081
1818
|
function createUpstashAuthHeader(email, apiKey) {
|
|
1082
1819
|
return `Basic ${Buffer.from(`${email}:${apiKey}`).toString("base64")}`;
|
|
@@ -1127,7 +1864,7 @@ function createUpstashProvisioner() {
|
|
|
1127
1864
|
name: "upstash",
|
|
1128
1865
|
serviceType: "cache",
|
|
1129
1866
|
async authenticate(credentials) {
|
|
1130
|
-
|
|
1867
|
+
logger13.debug("Authenticating with Upstash");
|
|
1131
1868
|
try {
|
|
1132
1869
|
const userEmail = credentials.email ?? "";
|
|
1133
1870
|
const userKey = credentials.apiKey ?? credentials.api_key ?? credentials.token ?? "";
|
|
@@ -1137,10 +1874,10 @@ function createUpstashProvisioner() {
|
|
|
1137
1874
|
await listUpstashDatabases(userEmail, userKey);
|
|
1138
1875
|
email = userEmail;
|
|
1139
1876
|
apiKey = userKey;
|
|
1140
|
-
|
|
1877
|
+
logger13.info("Upstash authenticated", { email: userEmail });
|
|
1141
1878
|
return ok(userEmail);
|
|
1142
1879
|
} catch (error) {
|
|
1143
|
-
|
|
1880
|
+
logger13.error("Upstash authentication failed", { error });
|
|
1144
1881
|
return err(
|
|
1145
1882
|
HaiDeployError.AUTH_FAILED,
|
|
1146
1883
|
deployM("deploy_authFailed", {
|
|
@@ -1157,7 +1894,7 @@ function createUpstashProvisioner() {
|
|
|
1157
1894
|
deployM("deploy_authRequired")
|
|
1158
1895
|
);
|
|
1159
1896
|
}
|
|
1160
|
-
|
|
1897
|
+
logger13.debug("Provisioning Upstash Redis", { appName });
|
|
1161
1898
|
let resource;
|
|
1162
1899
|
try {
|
|
1163
1900
|
const databaseName = `${appName}-cache`;
|
|
@@ -1169,7 +1906,7 @@ function createUpstashProvisioner() {
|
|
|
1169
1906
|
}
|
|
1170
1907
|
resource = { resourceInfo: `upstash-db:${databaseId}`, resourceStatus: "reused" };
|
|
1171
1908
|
const detail = await getUpstashDatabase(email, apiKey, databaseId);
|
|
1172
|
-
|
|
1909
|
+
logger13.info("Upstash Redis reused", { databaseId, databaseName });
|
|
1173
1910
|
return ok(buildUpstashProvisionResult({ ...existingDatabase, ...detail }, "reused"));
|
|
1174
1911
|
}
|
|
1175
1912
|
const data = await upstashFetch(email, apiKey, "/v2/redis/database", {
|
|
@@ -1182,10 +1919,10 @@ function createUpstashProvisioner() {
|
|
|
1182
1919
|
});
|
|
1183
1920
|
if (data.database_id)
|
|
1184
1921
|
resource = { resourceInfo: `upstash-db:${data.database_id}`, resourceStatus: "created" };
|
|
1185
|
-
|
|
1922
|
+
logger13.info("Upstash Redis provisioned", { databaseId: data.database_id });
|
|
1186
1923
|
return ok(buildUpstashProvisionResult(data, "created"));
|
|
1187
1924
|
} catch (error) {
|
|
1188
|
-
|
|
1925
|
+
logger13.error("Upstash provisioning failed", { appName });
|
|
1189
1926
|
if (resource) {
|
|
1190
1927
|
return recoveryFailure({ ...HaiDeployError.PROVISION_FAILED}, {
|
|
1191
1928
|
projectName: appName,
|
|
@@ -1209,9 +1946,10 @@ function createUpstashProvisioner() {
|
|
|
1209
1946
|
}
|
|
1210
1947
|
|
|
1211
1948
|
// src/deploy-main.ts
|
|
1212
|
-
var
|
|
1949
|
+
var logger14 = core.logger.child({ module: "deploy", scope: "main" });
|
|
1213
1950
|
var currentConfig = null;
|
|
1214
1951
|
var currentProvider = null;
|
|
1952
|
+
var currentContainerProvider = null;
|
|
1215
1953
|
var currentProvisioners = /* @__PURE__ */ new Map();
|
|
1216
1954
|
var initInProgress = false;
|
|
1217
1955
|
var notInitialized = core.module.createNotInitializedKit(
|
|
@@ -1257,11 +1995,27 @@ var deployCredentials = {
|
|
|
1257
1995
|
save: (key, value) => saveCredential(key, value),
|
|
1258
1996
|
saveAll: (entries) => saveCredentials(entries)
|
|
1259
1997
|
};
|
|
1998
|
+
function normalizeProjectName(name) {
|
|
1999
|
+
const normalized = name.replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2000
|
+
return normalized || "app";
|
|
2001
|
+
}
|
|
2002
|
+
function mergeProvisionEnv(results) {
|
|
2003
|
+
let env = {};
|
|
2004
|
+
const reachProviders = [];
|
|
2005
|
+
for (const prov of results) {
|
|
2006
|
+
if (prov.envVars.HAI_REACH_PROVIDERS)
|
|
2007
|
+
reachProviders.push(...JSON.parse(prov.envVars.HAI_REACH_PROVIDERS));
|
|
2008
|
+
env = { ...env, ...prov.envVars };
|
|
2009
|
+
}
|
|
2010
|
+
if (reachProviders.length > 0)
|
|
2011
|
+
env.HAI_REACH_PROVIDERS = JSON.stringify(reachProviders);
|
|
2012
|
+
return env;
|
|
2013
|
+
}
|
|
1260
2014
|
var deploy = {
|
|
1261
2015
|
credentials: deployCredentials,
|
|
1262
2016
|
async init(config) {
|
|
1263
2017
|
if (initInProgress) {
|
|
1264
|
-
|
|
2018
|
+
logger14.warn("Deploy init already in progress, skipping concurrent call");
|
|
1265
2019
|
return err(
|
|
1266
2020
|
HaiDeployError.CONFIG_ERROR,
|
|
1267
2021
|
deployM("deploy_configError", { params: { error: "init already in progress" } })
|
|
@@ -1269,14 +2023,14 @@ var deploy = {
|
|
|
1269
2023
|
}
|
|
1270
2024
|
initInProgress = true;
|
|
1271
2025
|
try {
|
|
1272
|
-
if (currentProvider) {
|
|
1273
|
-
|
|
2026
|
+
if (currentProvider || currentContainerProvider) {
|
|
2027
|
+
logger14.warn("Deploy module is already initialized, reinitializing");
|
|
1274
2028
|
await deploy.close();
|
|
1275
2029
|
}
|
|
1276
|
-
|
|
2030
|
+
logger14.info("Initializing deploy module");
|
|
1277
2031
|
const parseResult = DeployConfigSchema.safeParse(config);
|
|
1278
2032
|
if (!parseResult.success) {
|
|
1279
|
-
|
|
2033
|
+
logger14.error("Deploy config validation failed", { error: parseResult.error.message });
|
|
1280
2034
|
return err(
|
|
1281
2035
|
HaiDeployError.CONFIG_ERROR,
|
|
1282
2036
|
deployM("deploy_configError", { params: { error: parseResult.error.message } }),
|
|
@@ -1285,12 +2039,19 @@ var deploy = {
|
|
|
1285
2039
|
}
|
|
1286
2040
|
const parsed = parseResult.data;
|
|
1287
2041
|
try {
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
if (
|
|
1291
|
-
|
|
2042
|
+
let provider = null;
|
|
2043
|
+
let containerProvider = null;
|
|
2044
|
+
if (parsed.provider.type === "docker-ssh") {
|
|
2045
|
+
containerProvider = createDockerSshProvider(parsed.provider, parsed.container?.runtime);
|
|
2046
|
+
logger14.info("Container deploy provider ready", { provider: "docker-ssh", host: parsed.provider.ssh.host });
|
|
2047
|
+
} else {
|
|
2048
|
+
provider = createProviderByType(parsed.provider.type);
|
|
2049
|
+
const authResult = await provider.authenticate(parsed.provider.token);
|
|
2050
|
+
if (!authResult.success) {
|
|
2051
|
+
return err(authResult.error);
|
|
2052
|
+
}
|
|
2053
|
+
logger14.info("Provider authenticated", { provider: parsed.provider.type, user: authResult.data });
|
|
1292
2054
|
}
|
|
1293
|
-
logger10.info("Provider authenticated", { provider: parsed.provider.type, user: authResult.data });
|
|
1294
2055
|
currentProvisioners.clear();
|
|
1295
2056
|
if (parsed.services) {
|
|
1296
2057
|
const serviceEntries = Object.entries(parsed.services);
|
|
@@ -1301,14 +2062,14 @@ var deploy = {
|
|
|
1301
2062
|
const credentials = extractCredentials(serviceConfig);
|
|
1302
2063
|
const provAuthResult = await provisioner.authenticate(credentials);
|
|
1303
2064
|
if (!provAuthResult.success) {
|
|
1304
|
-
|
|
2065
|
+
logger14.error("Provisioner authentication failed", {
|
|
1305
2066
|
service: serviceType,
|
|
1306
2067
|
provisioner: serviceConfig.provisioner
|
|
1307
2068
|
});
|
|
1308
2069
|
currentProvisioners.clear();
|
|
1309
2070
|
return err(provAuthResult.error);
|
|
1310
2071
|
}
|
|
1311
|
-
|
|
2072
|
+
logger14.info("Provisioner authenticated", {
|
|
1312
2073
|
service: serviceType,
|
|
1313
2074
|
user: provAuthResult.data
|
|
1314
2075
|
});
|
|
@@ -1316,17 +2077,19 @@ var deploy = {
|
|
|
1316
2077
|
}
|
|
1317
2078
|
}
|
|
1318
2079
|
currentProvider = provider;
|
|
2080
|
+
currentContainerProvider = containerProvider;
|
|
1319
2081
|
currentConfig = parsed;
|
|
1320
|
-
|
|
2082
|
+
logger14.info("Deploy module initialized", {
|
|
1321
2083
|
provider: parsed.provider.type,
|
|
1322
2084
|
provisioners: [...currentProvisioners.keys()]
|
|
1323
2085
|
});
|
|
1324
2086
|
return ok(void 0);
|
|
1325
2087
|
} catch (error) {
|
|
1326
2088
|
currentProvider = null;
|
|
2089
|
+
currentContainerProvider = null;
|
|
1327
2090
|
currentConfig = null;
|
|
1328
2091
|
currentProvisioners.clear();
|
|
1329
|
-
|
|
2092
|
+
logger14.error("Deploy module initialization failed", { error });
|
|
1330
2093
|
return err(
|
|
1331
2094
|
HaiDeployError.CONFIG_ERROR,
|
|
1332
2095
|
deployM("deploy_configError", {
|
|
@@ -1340,37 +2103,38 @@ var deploy = {
|
|
|
1340
2103
|
}
|
|
1341
2104
|
},
|
|
1342
2105
|
async close() {
|
|
1343
|
-
if (!currentProvider) {
|
|
1344
|
-
|
|
2106
|
+
if (!currentProvider && !currentContainerProvider) {
|
|
2107
|
+
logger14.info("Deploy module already closed, skipping");
|
|
1345
2108
|
return;
|
|
1346
2109
|
}
|
|
1347
|
-
|
|
2110
|
+
logger14.info("Closing deploy module");
|
|
1348
2111
|
currentProvider = null;
|
|
2112
|
+
currentContainerProvider = null;
|
|
1349
2113
|
currentConfig = null;
|
|
1350
2114
|
currentProvisioners.clear();
|
|
1351
|
-
|
|
2115
|
+
logger14.info("Deploy module closed");
|
|
1352
2116
|
},
|
|
1353
2117
|
get config() {
|
|
1354
2118
|
return currentConfig;
|
|
1355
2119
|
},
|
|
1356
2120
|
get isInitialized() {
|
|
1357
|
-
return currentProvider !== null;
|
|
2121
|
+
return currentProvider !== null || currentContainerProvider !== null;
|
|
1358
2122
|
},
|
|
1359
2123
|
async scan(appDir) {
|
|
1360
|
-
|
|
2124
|
+
logger14.debug("Scanning application", { appDir });
|
|
1361
2125
|
return scanApp(appDir);
|
|
1362
2126
|
},
|
|
1363
2127
|
async provisionAll(projectName) {
|
|
1364
|
-
if (!currentProvider) {
|
|
2128
|
+
if (!currentProvider && !currentContainerProvider) {
|
|
1365
2129
|
return notInitialized.result();
|
|
1366
2130
|
}
|
|
1367
|
-
|
|
2131
|
+
logger14.info("Provisioning all services", { projectName, count: currentProvisioners.size });
|
|
1368
2132
|
const results = [];
|
|
1369
2133
|
for (const [serviceType, provisioner] of currentProvisioners) {
|
|
1370
|
-
|
|
2134
|
+
logger14.debug("Provisioning service", { serviceType, provisioner: provisioner.name });
|
|
1371
2135
|
const provResult = await provisioner.provision(projectName);
|
|
1372
2136
|
if (!provResult.success) {
|
|
1373
|
-
|
|
2137
|
+
logger14.error("Service provisioning failed", {
|
|
1374
2138
|
serviceType,
|
|
1375
2139
|
provisioner: provisioner.name,
|
|
1376
2140
|
code: provResult.error.code
|
|
@@ -1387,21 +2151,27 @@ var deploy = {
|
|
|
1387
2151
|
});
|
|
1388
2152
|
}
|
|
1389
2153
|
results.push(provResult.data);
|
|
1390
|
-
|
|
2154
|
+
logger14.info("Service provisioned", { serviceType, provisioner: provisioner.name });
|
|
1391
2155
|
}
|
|
1392
2156
|
return ok(results);
|
|
1393
2157
|
},
|
|
1394
2158
|
async deployApp(appDir, options) {
|
|
1395
|
-
if (!currentProvider) {
|
|
2159
|
+
if (!currentProvider && !currentContainerProvider) {
|
|
1396
2160
|
return notInitialized.result();
|
|
1397
2161
|
}
|
|
1398
|
-
|
|
2162
|
+
logger14.info("Starting deployment", { appDir, options });
|
|
1399
2163
|
const scanResult = await scanApp(appDir);
|
|
1400
2164
|
if (!scanResult.success) {
|
|
1401
2165
|
return err(scanResult.error);
|
|
1402
2166
|
}
|
|
1403
2167
|
const scan = scanResult.data;
|
|
1404
2168
|
const projectName = options?.projectName ?? scan.appName;
|
|
2169
|
+
if (currentContainerProvider) {
|
|
2170
|
+
return deployContainerApp(currentContainerProvider, appDir, scan, projectName, options);
|
|
2171
|
+
}
|
|
2172
|
+
if (!currentProvider) {
|
|
2173
|
+
return notInitialized.result();
|
|
2174
|
+
}
|
|
1405
2175
|
if (scan.isSvelteKit && !scan.adapterInstalled) {
|
|
1406
2176
|
return err(
|
|
1407
2177
|
HaiDeployError.ADAPTER_MISSING,
|
|
@@ -1418,14 +2188,7 @@ var deploy = {
|
|
|
1418
2188
|
return err(provResults.error);
|
|
1419
2189
|
}
|
|
1420
2190
|
recovery.resources = provResults.data.map(({ envVars: _envVars, ...resource }) => resource);
|
|
1421
|
-
|
|
1422
|
-
for (const prov of provResults.data) {
|
|
1423
|
-
if (prov.envVars.HAI_REACH_PROVIDERS)
|
|
1424
|
-
reachProviders.push(...JSON.parse(prov.envVars.HAI_REACH_PROVIDERS));
|
|
1425
|
-
allEnvVars = { ...allEnvVars, ...prov.envVars };
|
|
1426
|
-
}
|
|
1427
|
-
if (reachProviders.length > 0)
|
|
1428
|
-
allEnvVars.HAI_REACH_PROVIDERS = JSON.stringify(reachProviders);
|
|
2191
|
+
allEnvVars = mergeProvisionEnv(provResults.data);
|
|
1429
2192
|
}
|
|
1430
2193
|
const projectResult = await currentProvider.createProject(projectName);
|
|
1431
2194
|
if (!projectResult.success) {
|
|
@@ -1454,7 +2217,7 @@ var deploy = {
|
|
|
1454
2217
|
...deployResult.data,
|
|
1455
2218
|
envVarsSet: Object.keys(allEnvVars)
|
|
1456
2219
|
};
|
|
1457
|
-
|
|
2220
|
+
logger14.info("Deployment completed", {
|
|
1458
2221
|
url: result.url,
|
|
1459
2222
|
deploymentId: result.deploymentId,
|
|
1460
2223
|
status: result.status
|
|
@@ -1462,6 +2225,24 @@ var deploy = {
|
|
|
1462
2225
|
return ok(result);
|
|
1463
2226
|
}
|
|
1464
2227
|
};
|
|
2228
|
+
async function deployContainerApp(provider, appDir, scan, rawProjectName, options) {
|
|
2229
|
+
const projectName = normalizeProjectName(rawProjectName);
|
|
2230
|
+
if (scan.isSvelteKit && !scan.nodeAdapterInstalled) {
|
|
2231
|
+
return err(
|
|
2232
|
+
HaiDeployError.ADAPTER_MISSING,
|
|
2233
|
+
deployM("deploy_adapterMissing", { params: { adapter: "@sveltejs/adapter-node" } })
|
|
2234
|
+
);
|
|
2235
|
+
}
|
|
2236
|
+
let provisionEnv = {};
|
|
2237
|
+
if (!options?.skipProvision) {
|
|
2238
|
+
const provResults = await deploy.provisionAll(projectName);
|
|
2239
|
+
if (!provResults.success) {
|
|
2240
|
+
return err(provResults.error);
|
|
2241
|
+
}
|
|
2242
|
+
provisionEnv = mergeProvisionEnv(provResults.data);
|
|
2243
|
+
}
|
|
2244
|
+
return provider.deploy({ appDir, projectName, scan, provisionEnv });
|
|
2245
|
+
}
|
|
1465
2246
|
|
|
1466
2247
|
export { DeployConfigSchema, HaiDeployError, ProviderConfigSchema, deploy, getDeployRecovery };
|
|
1467
2248
|
//# sourceMappingURL=index.js.map
|