@h-ai/deploy 0.1.0-alpha.65 → 0.1.0-alpha.67
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 +80 -143
- package/README.ja.md +81 -144
- package/README.md +112 -145
- package/dist/index.d.ts +61 -26
- package/dist/index.js +956 -506
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,16 +1,60 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { readFileSync, existsSync, writeFileSync, chmodSync, mkdirSync, statSync, readdirSync } from 'fs';
|
|
1
|
+
import { createHash, randomBytes } from 'crypto';
|
|
2
|
+
import { createReadStream, readFileSync, existsSync, lstatSync, writeFileSync, chmodSync, mkdirSync, mkdtempSync, copyFileSync, renameSync, rmSync, statSync, readdirSync } from 'fs';
|
|
4
3
|
import { resolve, join, basename, dirname, relative, sep } from 'path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { core, err, ok } from '@h-ai/core';
|
|
5
6
|
import process2 from 'process';
|
|
6
7
|
import { parseEnv } from 'util';
|
|
7
|
-
import { parse, stringify, parseAllDocuments } from 'yaml';
|
|
8
|
+
import { LineCounter, parseDocument, parse, stringify, parseAllDocuments } from 'yaml';
|
|
8
9
|
import { homedir } from 'os';
|
|
9
|
-
import {
|
|
10
|
-
import { randomBytes, createHash } from 'crypto';
|
|
10
|
+
import { spawn } from 'child_process';
|
|
11
11
|
import { Buffer } from 'buffer';
|
|
12
12
|
|
|
13
|
-
// src/deploy-
|
|
13
|
+
// src/deploy-artifact.ts
|
|
14
|
+
var DeploymentArtifactSchema = z.object({
|
|
15
|
+
/** 清单格式版本,与应用版本和部署环境无关。 */
|
|
16
|
+
version: z.literal(3),
|
|
17
|
+
/** 应用根 package.json 的版本,用于归档、Compose 与桌面交付;最长 80 字符。 */
|
|
18
|
+
appVersion: z.string().max(80).regex(/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9a-z-]+(?:\.[0-9a-z-]+)*)?(?:\+[0-9a-z-]+(?:\.[0-9a-z-]+)*)?$/i),
|
|
19
|
+
/** 本次构建标识;同一应用版本允许重复构建。 */
|
|
20
|
+
buildId: z.string().regex(/^[a-z0-9][\w.+-]*$/i),
|
|
21
|
+
/** 只包含所选镜像的构建不能用于首次安装。 */
|
|
22
|
+
partial: z.boolean().default(false),
|
|
23
|
+
/** API 单独构建时仍保留工程的 Web 拓扑。 */
|
|
24
|
+
hasWeb: z.boolean().default(false),
|
|
25
|
+
/** 工程显式配置的应用端口,部分构建未包含 app 镜像时仍保留;缺省由部署配置决定。 */
|
|
26
|
+
appContainerPort: z.number().int().min(1).max(65535).optional(),
|
|
27
|
+
/** 工程显式配置的 Web 端口,部分构建未包含 web 镜像时仍保留;缺省使用部署默认值。 */
|
|
28
|
+
webContainerPort: z.number().int().min(1).max(65535).optional(),
|
|
29
|
+
/** 本次构建选择的镜像归档。 */
|
|
30
|
+
images: z.array(z.object({
|
|
31
|
+
/** app 为服务或独立应用,web 为同源 API 代理的静态前端。 */
|
|
32
|
+
name: z.enum(["app", "web"]),
|
|
33
|
+
/** 相对于清单目录的 tar 文件名。 */
|
|
34
|
+
file: z.string().regex(/^[\w.+-]+\.tar$/),
|
|
35
|
+
/** 归档导入后可直接使用的不可变镜像引用。 */
|
|
36
|
+
imageRef: z.string().regex(/^[a-z0-9][a-z0-9./:_-]*:[a-z0-9_][a-z0-9_.-]*$/).refine((value) => !value.endsWith(":latest")),
|
|
37
|
+
/** tar 内容的 SHA-256,用于部署前校验。 */
|
|
38
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
39
|
+
/** 容器内部监听端口;服务器公开端口由部署选择。 */
|
|
40
|
+
containerPort: z.number().int().min(1).max(65535).optional()
|
|
41
|
+
})).min(1).max(2).refine((images) => new Set(images.map((image) => image.name)).size === images.length)
|
|
42
|
+
}).refine((artifact) => artifact.images.every((image) => image.file === `${image.name}-${artifact.appVersion}.tar`)).refine((artifact) => artifact.partial || artifact.images.some((image) => image.name === "app")).refine((artifact) => artifact.partial || !artifact.hasWeb || artifact.images.some((image) => image.name === "web"));
|
|
43
|
+
async function artifactChecksum(file) {
|
|
44
|
+
const hash = createHash("sha256");
|
|
45
|
+
for await (const chunk of createReadStream(file))
|
|
46
|
+
hash.update(chunk);
|
|
47
|
+
return hash.digest("hex");
|
|
48
|
+
}
|
|
49
|
+
async function readDeploymentArtifact(directory) {
|
|
50
|
+
const artifact = DeploymentArtifactSchema.parse(JSON.parse(readFileSync(resolve(directory, "manifest.json"), "utf8")));
|
|
51
|
+
for (const image of artifact.images) {
|
|
52
|
+
const file = resolve(directory, image.file);
|
|
53
|
+
if (!existsSync(file) || !lstatSync(file).isFile() || lstatSync(file).isSymbolicLink() || await artifactChecksum(file) !== image.sha256)
|
|
54
|
+
throw new Error(`Invalid artifact: ${image.file}`);
|
|
55
|
+
}
|
|
56
|
+
return artifact;
|
|
57
|
+
}
|
|
14
58
|
|
|
15
59
|
// messages/en-US.json
|
|
16
60
|
var en_US_default = {
|
|
@@ -22,7 +66,6 @@ var en_US_default = {
|
|
|
22
66
|
deploy_authRequired: "Authentication required",
|
|
23
67
|
deploy_authFailed: "Authentication failed: {error}",
|
|
24
68
|
deploy_projectCreateFailed: "Project creation failed: {error}",
|
|
25
|
-
deploy_buildFailed: "Application build failed: {error}",
|
|
26
69
|
deploy_uploadFailed: "Build output upload failed: {error}",
|
|
27
70
|
deploy_deployFailed: "Deployment failed: {error}",
|
|
28
71
|
deploy_provisionFailed: "Service provisioning failed: {service} - {error}",
|
|
@@ -42,8 +85,6 @@ var en_US_default = {
|
|
|
42
85
|
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.",
|
|
43
86
|
deploy_commandTimeout: "Command timed out: {command}",
|
|
44
87
|
deploy_commandFailed: "Command failed ({command}): {error}",
|
|
45
|
-
deploy_containerRuntimeNotFound: "No usable container runtime found: {runtime}",
|
|
46
|
-
deploy_containerBuildFailed: "Container image build failed: {error}",
|
|
47
88
|
deploy_remoteConnectFailed: "Cannot connect to remote host {host}: {error}",
|
|
48
89
|
deploy_remoteRuntimeMissing: "Remote host missing container runtime or compose: {runtime}",
|
|
49
90
|
deploy_imageTransferFailed: "Image transfer failed: {error}",
|
|
@@ -64,7 +105,23 @@ var en_US_default = {
|
|
|
64
105
|
deploy_purposeLdap: "Directory account authentication; the default directory has no business users",
|
|
65
106
|
deploy_purposeConsole: "Messages go to application logs without delivery",
|
|
66
107
|
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"
|
|
108
|
+
deploy_purposeSms: "SMS through Aliyun credentials; requires an existing signature and quota",
|
|
109
|
+
deploy_webProxyInvalid: "The API prefix must be an absolute path containing only letters, numbers, underscores, hyphens and slashes.",
|
|
110
|
+
deploy_artifactInvalid: "Image artifacts are missing or invalid. Run pnpm build --target docker, select valid --artifact-dir, or use pnpm run deploy --build.",
|
|
111
|
+
deploy_sshRequired: "Automatic deployment requires SSH settings. Use --bundle-only for manual transfer when the server is unreachable.",
|
|
112
|
+
deploy_bundleInstructions: "Bundle: {file}\nIncludes all images needed by selected services, including self-hosted middleware.\nWith SSH access, run deploy.ps1 (Windows) or deploy.sh (Linux/macOS) from the adjacent deployment directory.\nFor isolated servers, copy and extract the tar into a separate directory, then run sh apply.sh /opt/hai/apps/project-environment.\nLinux requires Docker/Podman, Compose, sh, tar, sha256sum, awk, curl and filesystem/container permissions.\nPartial updates require a successful full installation with matching ports/layout. Steps return nonzero on failure; result-DEPLOYMENT_ID.json records the outcome.\nThe bundle contains private runtime configuration; transfer it securely.\nIncluded images:\n{images}",
|
|
113
|
+
deploy_bundleMetadata: "Application version: {version}\nBuild ID: {buildId}\nCompose file: {compose}",
|
|
114
|
+
deploy_bundleOnlyContainer: "Compose bundle generation is supported only by docker-ssh.",
|
|
115
|
+
deploy_selectedContainerOnly: "Selected-service deployment requires a docker-ssh Compose destination.",
|
|
116
|
+
deploy_selectionInvalid: "Cannot deploy {service}: check the service name and build its image first.",
|
|
117
|
+
deploy_imageVersionRequired: "Specify an image version; latest is not allowed: {image}",
|
|
118
|
+
deploy_bundleRuntimeRequired: "Start local Podman/Docker to export middleware images, or prepare this complete offline bundle on the build machine.",
|
|
119
|
+
deploy_bundleFailed: "Package preparation or deployment failed: {reason}",
|
|
120
|
+
deploy_configFileInvalid: "Invalid YAML in {path}, line {line}. Check indentation, colons and quotes.",
|
|
121
|
+
deploy_configFileUnreadable: "Cannot read configuration file {path}. Check its path and permissions.",
|
|
122
|
+
deploy_bundleNameInvalid: "Bundle filenames may contain letters, numbers, dots, plus signs, hyphens and underscores; parent directories may contain spaces.",
|
|
123
|
+
deploy_secretsInvalid: "Middleware credentials are damaged or incomplete: {path}. Restore a backup; passwords are not regenerated because existing data may become inaccessible.",
|
|
124
|
+
deploy_storageArtifactRequired: "Storage initialization requires the application image. Select a complete artifact containing app with --artifact-dir, or run pnpm build --target docker --images app first."
|
|
68
125
|
};
|
|
69
126
|
|
|
70
127
|
// messages/zh-CN.json
|
|
@@ -77,7 +134,6 @@ var zh_CN_default = {
|
|
|
77
134
|
deploy_authRequired: "\u8BF7\u5148\u5B8C\u6210\u8BA4\u8BC1",
|
|
78
135
|
deploy_authFailed: "\u8BA4\u8BC1\u5931\u8D25\uFF1A{error}",
|
|
79
136
|
deploy_projectCreateFailed: "\u9879\u76EE\u521B\u5EFA\u5931\u8D25\uFF1A{error}",
|
|
80
|
-
deploy_buildFailed: "\u5E94\u7528\u6784\u5EFA\u5931\u8D25\uFF1A{error}",
|
|
81
137
|
deploy_uploadFailed: "\u6784\u5EFA\u4EA7\u7269\u4E0A\u4F20\u5931\u8D25\uFF1A{error}",
|
|
82
138
|
deploy_deployFailed: "\u90E8\u7F72\u5931\u8D25\uFF1A{error}",
|
|
83
139
|
deploy_provisionFailed: "\u57FA\u7840\u8BBE\u65BD\u5F00\u901A\u5931\u8D25\uFF1A{service} - {error}",
|
|
@@ -97,8 +153,6 @@ var zh_CN_default = {
|
|
|
97
153
|
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",
|
|
98
154
|
deploy_commandTimeout: "\u547D\u4EE4\u6267\u884C\u8D85\u65F6\uFF1A{command}",
|
|
99
155
|
deploy_commandFailed: "\u547D\u4EE4\u6267\u884C\u5931\u8D25\uFF08{command}\uFF09\uFF1A{error}",
|
|
100
|
-
deploy_containerRuntimeNotFound: "\u672A\u627E\u5230\u53EF\u7528\u7684\u5BB9\u5668\u8FD0\u884C\u65F6\uFF1A{runtime}",
|
|
101
|
-
deploy_containerBuildFailed: "\u5BB9\u5668\u955C\u50CF\u6784\u5EFA\u5931\u8D25\uFF1A{error}",
|
|
102
156
|
deploy_remoteConnectFailed: "\u65E0\u6CD5\u8FDE\u63A5\u8FDC\u7A0B\u4E3B\u673A {host}\uFF1A{error}",
|
|
103
157
|
deploy_remoteRuntimeMissing: "\u8FDC\u7A0B\u4E3B\u673A\u7F3A\u5C11\u5BB9\u5668\u8FD0\u884C\u65F6\u6216 compose\uFF1A{runtime}",
|
|
104
158
|
deploy_imageTransferFailed: "\u955C\u50CF\u4F20\u8F93\u5931\u8D25\uFF1A{error}",
|
|
@@ -119,7 +173,23 @@ var zh_CN_default = {
|
|
|
119
173
|
deploy_purposeLdap: "\u4F01\u4E1A\u76EE\u5F55\u8D26\u53F7\u8BA4\u8BC1\uFF1B\u9ED8\u8BA4\u76EE\u5F55\u4E0D\u5305\u542B\u4E1A\u52A1\u7528\u6237",
|
|
120
174
|
deploy_purposeConsole: "\u6D88\u606F\u8F93\u51FA\u5230\u5E94\u7528\u65E5\u5FD7\uFF0C\u4E0D\u5B9E\u9645\u6295\u9012",
|
|
121
175
|
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"
|
|
176
|
+
deploy_purposeSms: "\u901A\u8FC7\u963F\u91CC\u4E91\u51ED\u8BC1\u63A5\u5165\u77ED\u4FE1\u670D\u52A1\uFF0C\u9700\u5DF2\u6709\u7B7E\u540D\u4E0E\u989D\u5EA6",
|
|
177
|
+
deploy_webProxyInvalid: "API \u524D\u7F00\u5FC5\u987B\u662F\u4EC5\u542B\u5B57\u6BCD\u3001\u6570\u5B57\u3001\u4E0B\u5212\u7EBF\u3001\u8FDE\u5B57\u7B26\u548C\u659C\u6760\u7684\u7EDD\u5BF9\u8DEF\u5F84\u3002",
|
|
178
|
+
deploy_artifactInvalid: "\u955C\u50CF\u4EA7\u7269\u7F3A\u5931\u6216\u6821\u9A8C\u5931\u8D25\u3002\u8BF7\u5148\u6267\u884C pnpm build --target docker\uFF0C\u6216\u7528 --artifact-dir \u6307\u5B9A\u6709\u6548\u4EA7\u7269\uFF1B\u4E5F\u53EF\u7528 pnpm run deploy --build \u4E00\u6B21\u5B8C\u6210\u3002",
|
|
179
|
+
deploy_sshRequired: "\u81EA\u52A8\u90E8\u7F72\u9700\u8981 SSH \u914D\u7F6E\uFF1B\u65E0\u7F51\u7EDC\u8FDE\u63A5\u65F6\u4F7F\u7528 --bundle-only \u751F\u6210\u90E8\u7F72\u5305\u5E76\u624B\u5DE5\u642C\u8FD0\u3002",
|
|
180
|
+
deploy_bundleInstructions: "\u90E8\u7F72\u5305\uFF1A{file}\n\u5305\u4E2D\u5DF2\u5305\u542B\u672C\u6B21\u9009\u62E9\u670D\u52A1\u9700\u8981\u7684\u955C\u50CF\uFF0C\u5305\u62EC\u81EA\u5EFA\u4E2D\u95F4\u4EF6\u3002\n\u6709 SSH \u8FDE\u63A5\u65F6\uFF0C\u5728\u65C1\u8FB9\u7684\u90E8\u7F72\u76EE\u5F55\u8FD0\u884C deploy.ps1\uFF08Windows\uFF09\u6216 deploy.sh\uFF08Linux/macOS\uFF09\u3002\n\u5B8C\u5168\u9694\u79BB\u65F6\uFF0C\u590D\u5236 tar \u5230 Linux \u670D\u52A1\u5668\uFF0C\u5728\u72EC\u7ACB\u76EE\u5F55\u89E3\u5305\uFF0C\u518D\u6267\u884C sh apply.sh /opt/hai/apps/\u9879\u76EE\u73AF\u5883\u3002\n\u670D\u52A1\u5668\u9700\u8981 Docker/Podman\u3001Compose\u3001sh\u3001tar\u3001sha256sum\u3001awk \u548C curl\uFF0C\u4EE5\u53CA\u76EE\u5F55\u3001\u5BB9\u5668\u8FD0\u884C\u6743\u9650\u3002\n\u90E8\u5206\u66F4\u65B0\u8981\u6C42\u670D\u52A1\u5668\u5DF2\u6709\u6210\u529F\u7684\u5B8C\u6574\u90E8\u7F72\uFF0C\u7AEF\u53E3\u53CA\u5E94\u7528\u5E03\u5C40\u4FDD\u6301\u4E00\u81F4\u3002\u6BCF\u6B65\u5931\u8D25\u4F1A\u8FD4\u56DE\u975E\u96F6\u9000\u51FA\u7801\uFF1B\u7ED3\u679C\u8BB0\u5F55\u5728\u90E8\u7F72\u76EE\u5F55 result-\u90E8\u7F72ID.json\u3002\n\u5305\u5305\u542B\u79C1\u6709\u8FD0\u884C\u914D\u7F6E\uFF0C\u8BF7\u5B89\u5168\u4F20\u8F93\u3002\n\u6240\u542B\u955C\u50CF\uFF1A\n{images}",
|
|
181
|
+
deploy_bundleMetadata: "\u5E94\u7528\u7248\u672C\uFF1A{version}\n\u6784\u5EFA\u7F16\u53F7\uFF1A{buildId}\nCompose \u6587\u4EF6\uFF1A{compose}",
|
|
182
|
+
deploy_bundleOnlyContainer: "\u4EC5\u751F\u6210 Compose tar \u53EA\u652F\u6301 docker-ssh\u3002",
|
|
183
|
+
deploy_selectedContainerOnly: "\u6309\u670D\u52A1\u5C40\u90E8\u90E8\u7F72\u4EC5\u9002\u7528\u4E8E docker-ssh Compose \u76EE\u6807\u3002",
|
|
184
|
+
deploy_selectionInvalid: "\u65E0\u6CD5\u90E8\u7F72\u670D\u52A1 {service}\uFF1A\u8BF7\u68C0\u67E5\u670D\u52A1\u540D\u79F0\uFF0C\u5E76\u5148\u6784\u5EFA\u5BF9\u5E94\u955C\u50CF\u3002",
|
|
185
|
+
deploy_storageArtifactRequired: "\u5B58\u50A8\u521D\u59CB\u5316\u9700\u8981\u5E94\u7528\u955C\u50CF\u3002\u8BF7\u7528 --artifact-dir \u9009\u62E9\u542B app \u955C\u50CF\u7684\u5B8C\u6574\u5236\u54C1\uFF0C\u6216\u5148\u6267\u884C pnpm build --target docker --images app\u3002",
|
|
186
|
+
deploy_imageVersionRequired: "\u955C\u50CF\u5FC5\u987B\u6307\u5B9A\u7248\u672C\uFF0C\u4E0D\u80FD\u4F7F\u7528 latest\uFF1A{image}",
|
|
187
|
+
deploy_bundleRuntimeRequired: "\u5B8C\u6574\u79BB\u7EBF\u5305\u9700\u8981\u5BFC\u51FA\u4E2D\u95F4\u4EF6\u955C\u50CF\u3002\u8BF7\u542F\u52A8\u672C\u5730 Podman/Docker\uFF0C\u6216\u5728\u6784\u5EFA\u673A\u6253\u5305\u3002",
|
|
188
|
+
deploy_bundleFailed: "\u4EA4\u4ED8\u51C6\u5907\u6216\u90E8\u7F72\u5931\u8D25\uFF1A{reason}",
|
|
189
|
+
deploy_configFileInvalid: "\u914D\u7F6E\u6587\u4EF6 {path} \u7B2C {line} \u884C\u683C\u5F0F\u6709\u8BEF\u3002\u8BF7\u68C0\u67E5\u7F29\u8FDB\u3001\u5192\u53F7\u548C\u5F15\u53F7\u540E\u91CD\u8BD5\u3002",
|
|
190
|
+
deploy_configFileUnreadable: "\u65E0\u6CD5\u8BFB\u53D6\u914D\u7F6E\u6587\u4EF6 {path}\uFF0C\u8BF7\u68C0\u67E5\u6587\u4EF6\u8DEF\u5F84\u4E0E\u8BFB\u53D6\u6743\u9650\u3002",
|
|
191
|
+
deploy_bundleNameInvalid: "\u90E8\u7F72\u5305\u6587\u4EF6\u540D\u53EA\u652F\u6301\u5B57\u6BCD\u3001\u6570\u5B57\u3001\u70B9\u3001\u52A0\u53F7\u3001\u77ED\u6A2A\u7EBF\u548C\u4E0B\u5212\u7EBF\uFF1B\u6240\u5728\u76EE\u5F55\u53EF\u4EE5\u5305\u542B\u7A7A\u683C\u3002",
|
|
192
|
+
deploy_secretsInvalid: "\u4E2D\u95F4\u4EF6\u51ED\u636E\u6587\u4EF6\u635F\u574F\u6216\u7F3A\u5C11\u5B57\u6BB5\uFF1A{path}\u3002\u8BF7\u4ECE\u5907\u4EFD\u6062\u590D\uFF1B\u4E0D\u4F1A\u81EA\u52A8\u66F4\u6362\u5BC6\u7801\uFF0C\u4EE5\u514D\u5DF2\u6709\u6570\u636E\u65E0\u6CD5\u8BBF\u95EE\u3002"
|
|
123
193
|
};
|
|
124
194
|
|
|
125
195
|
// src/deploy-i18n.ts
|
|
@@ -135,11 +205,11 @@ var VercelProviderConfigSchema = z.object({
|
|
|
135
205
|
});
|
|
136
206
|
var SshConfigSchema = z.object({
|
|
137
207
|
/** 远程主机地址或 ~/.ssh/config 中的 Host 别名 */
|
|
138
|
-
host: z.string().
|
|
208
|
+
host: z.string().regex(/^[a-z0-9][a-z0-9.:-]*$/i),
|
|
139
209
|
/** SSH 端口 */
|
|
140
210
|
port: z.number().int().min(1).max(65535).default(22),
|
|
141
211
|
/** SSH 用户名 */
|
|
142
|
-
username: z.string().
|
|
212
|
+
username: z.string().regex(/^[a-z_][\w-]*$/i),
|
|
143
213
|
/** 私钥文件路径(省略时使用 ssh-agent 或 ~/.ssh 默认密钥) */
|
|
144
214
|
identityFile: z.string().min(1).optional(),
|
|
145
215
|
/** known_hosts 文件路径(省略时使用默认;配合 CI/临时环境隔离主机指纹) */
|
|
@@ -160,11 +230,11 @@ var ExposeSchema = PortExposeSchema;
|
|
|
160
230
|
var DockerSshProviderConfigSchema = z.object({
|
|
161
231
|
type: z.literal("docker-ssh"),
|
|
162
232
|
/** SSH 连接参数 */
|
|
163
|
-
ssh: SshConfigSchema,
|
|
233
|
+
ssh: SshConfigSchema.optional(),
|
|
164
234
|
/** 远程主机运行参数 */
|
|
165
235
|
remote: z.object({
|
|
166
236
|
/** 远程应用根目录 */
|
|
167
|
-
baseDir: z.string().
|
|
237
|
+
baseDir: z.string().regex(/^\/[\w/. -]+$/).refine((value) => !value.split("/").includes("..")).default("/opt/hai/apps"),
|
|
168
238
|
/** 远程容器运行时(auto 优先 podman) */
|
|
169
239
|
runtime: z.enum(["auto", "docker", "podman"]).default("auto")
|
|
170
240
|
}).default({ baseDir: "/opt/hai/apps", runtime: "auto" }),
|
|
@@ -214,24 +284,15 @@ var DeployConfigSchema = z.object({
|
|
|
214
284
|
environment: z.string().regex(/^[a-z0-9][\w-]*$/i).refine((value) => !["local", "example"].includes(value.toLowerCase())).optional(),
|
|
215
285
|
provider: ProviderConfigSchema,
|
|
216
286
|
services: ServicesConfigSchema,
|
|
217
|
-
/**
|
|
287
|
+
/** Docker Compose 部署参数;构建配置位于项目 hai.build.json。 */
|
|
218
288
|
container: z.object({
|
|
219
|
-
runtime: z.enum(["auto", "docker", "podman"]).default("auto"),
|
|
220
|
-
/** Podman 构建是否继承代理;容器不能访问宿主机 localhost 代理时设为 false。 */
|
|
221
|
-
httpProxy: z.boolean().default(true),
|
|
222
289
|
/** 自建 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()
|
|
290
|
+
s3Engine: z.enum(["rustfs", "minio"]).default("rustfs")
|
|
229
291
|
}).optional()
|
|
230
292
|
});
|
|
231
293
|
var DeployErrorInfo = {
|
|
232
294
|
DEPLOY_FAILED: "001:500",
|
|
233
295
|
PROJECT_CREATE_FAILED: "002:500",
|
|
234
|
-
BUILD_FAILED: "003:500",
|
|
235
296
|
UPLOAD_FAILED: "004:500",
|
|
236
297
|
AUTH_REQUIRED: "005:401",
|
|
237
298
|
AUTH_FAILED: "006:401",
|
|
@@ -243,8 +304,6 @@ var DeployErrorInfo = {
|
|
|
243
304
|
UNSUPPORTED_TYPE: "012:400",
|
|
244
305
|
CONFIG_ERROR: "013:500",
|
|
245
306
|
CREDENTIAL_ERROR: "014:500",
|
|
246
|
-
CONTAINER_RUNTIME_NOT_FOUND: "015:500",
|
|
247
|
-
CONTAINER_BUILD_FAILED: "016:500",
|
|
248
307
|
REMOTE_CONNECT_FAILED: "017:500",
|
|
249
308
|
REMOTE_COMMAND_FAILED: "018:500",
|
|
250
309
|
IMAGE_TRANSFER_FAILED: "019:500",
|
|
@@ -253,9 +312,16 @@ var DeployErrorInfo = {
|
|
|
253
312
|
var HaiDeployError = core.error.buildHaiErrorsDef("deploy", DeployErrorInfo);
|
|
254
313
|
|
|
255
314
|
// src/deploy-environment.ts
|
|
315
|
+
function environmentValue(value) {
|
|
316
|
+
try {
|
|
317
|
+
return parse(value) ?? value;
|
|
318
|
+
} catch {
|
|
319
|
+
return value;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
256
322
|
function resolveConfig(value, prefix, env) {
|
|
257
323
|
if (env[prefix] !== void 0)
|
|
258
|
-
return
|
|
324
|
+
return environmentValue(env[prefix]);
|
|
259
325
|
if (Array.isArray(value))
|
|
260
326
|
return value.map((item, index) => resolveConfig(item, `${prefix}_${index}`, env));
|
|
261
327
|
if (value && typeof value === "object")
|
|
@@ -263,14 +329,20 @@ function resolveConfig(value, prefix, env) {
|
|
|
263
329
|
if (typeof value !== "string")
|
|
264
330
|
return value;
|
|
265
331
|
const resolved = value.replace(/\$\{([^:}]+)(?::([^}]*))?\}/g, (_match, name, fallback) => env[name] ?? fallback ?? "");
|
|
266
|
-
return /^\$\{[^}]+\}$/.test(value) ?
|
|
332
|
+
return /^\$\{[^}]+\}$/.test(value) ? environmentValue(resolved) : resolved;
|
|
267
333
|
}
|
|
268
334
|
function loadDeploymentConfig(name, path, values) {
|
|
269
335
|
try {
|
|
270
|
-
const
|
|
336
|
+
const lineCounter = new LineCounter();
|
|
337
|
+
const document = parseDocument(readFileSync(path, "utf8"), { lineCounter, prettyErrors: false });
|
|
338
|
+
if (document.errors.length)
|
|
339
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_configFileInvalid", { params: { path, line: String(lineCounter.linePos(document.errors[0].pos[0]).line) } }));
|
|
340
|
+
const config = resolveConfig(document.toJS(), `HAI_${name.toUpperCase()}`, values);
|
|
341
|
+
if (config !== null && (typeof config !== "object" || Array.isArray(config)))
|
|
342
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_configFileInvalid", { params: { path, line: "1" } }));
|
|
271
343
|
return ok(config && typeof config === "object" && !Array.isArray(config) ? config : {});
|
|
272
344
|
} catch {
|
|
273
|
-
return err(HaiDeployError.CONFIG_ERROR, deployM("
|
|
345
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_configFileUnreadable", { params: { path } }));
|
|
274
346
|
}
|
|
275
347
|
}
|
|
276
348
|
function loadDeploymentEnvironment(options) {
|
|
@@ -531,39 +603,25 @@ function saveCredentials(entries) {
|
|
|
531
603
|
);
|
|
532
604
|
}
|
|
533
605
|
}
|
|
534
|
-
|
|
606
|
+
function resolveArtifactDirectory(appDir) {
|
|
607
|
+
let directory = resolve(appDir);
|
|
608
|
+
while (true) {
|
|
609
|
+
if (existsSync(join(directory, "hai.build.json")) || existsSync(join(directory, "pnpm-lock.yaml")))
|
|
610
|
+
return join(directory, ".hai/build/docker");
|
|
611
|
+
const parent = dirname(directory);
|
|
612
|
+
if (parent === directory)
|
|
613
|
+
return join(appDir, ".hai/build/docker");
|
|
614
|
+
directory = parent;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
535
617
|
function usesDefaultMiddleware(scan, service, options) {
|
|
536
618
|
const requirements = scan.middlewares?.filter((item) => item.service === service) ?? [];
|
|
537
619
|
return requirements.length ? requirements.some((item) => (options?.serviceModes?.[item.envPrefix] ?? options?.serviceModes?.[service] ?? "default") === "default") : (options?.serviceModes?.[service] ?? "default") === "default";
|
|
538
620
|
}
|
|
539
|
-
function buildApp(appDir, buildCommand, envVars = {}) {
|
|
540
|
-
logger2.info("Building application", { appDir, buildCommand });
|
|
541
|
-
try {
|
|
542
|
-
execSync(buildCommand, {
|
|
543
|
-
cwd: appDir,
|
|
544
|
-
stdio: "inherit",
|
|
545
|
-
env: { ...process2.env, ...envVars }
|
|
546
|
-
});
|
|
547
|
-
return ok(void 0);
|
|
548
|
-
} catch (error) {
|
|
549
|
-
return err(
|
|
550
|
-
HaiDeployError.BUILD_FAILED,
|
|
551
|
-
deployM("deploy_buildFailed", {
|
|
552
|
-
params: { error: error instanceof Error ? error.message : String(error) }
|
|
553
|
-
}),
|
|
554
|
-
error
|
|
555
|
-
);
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
621
|
function resolveOutputDir(appDir, providerType) {
|
|
559
622
|
switch (providerType) {
|
|
560
|
-
case "vercel":
|
|
561
|
-
|
|
562
|
-
if (existsSync(vercelOutput)) {
|
|
563
|
-
return vercelOutput;
|
|
564
|
-
}
|
|
565
|
-
return join(appDir, "build");
|
|
566
|
-
}
|
|
623
|
+
case "vercel":
|
|
624
|
+
return join(appDir, ".vercel", "output");
|
|
567
625
|
default:
|
|
568
626
|
return join(appDir, "build");
|
|
569
627
|
}
|
|
@@ -594,7 +652,7 @@ function recoveryFailure(error, recovery) {
|
|
|
594
652
|
ext: { recovery }
|
|
595
653
|
});
|
|
596
654
|
}
|
|
597
|
-
var
|
|
655
|
+
var logger2 = core.logger.child({ module: "deploy", scope: "scanner" });
|
|
598
656
|
function record(value) {
|
|
599
657
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
600
658
|
}
|
|
@@ -713,26 +771,522 @@ async function scanApp(inputDir, options) {
|
|
|
713
771
|
adapterInstalled: graph.build.has("@sveltejs/adapter-vercel"),
|
|
714
772
|
nodeAdapterInstalled: graph.build.has("@sveltejs/adapter-node"),
|
|
715
773
|
requiredServices: [...new Set(middlewares.map((item) => item.service))],
|
|
716
|
-
serviceBackends: Object.fromEntries(middlewares.map((item) => [item.service, item.backend]))
|
|
717
|
-
buildCommand: "pnpm build"
|
|
774
|
+
serviceBackends: Object.fromEntries(middlewares.map((item) => [item.service, item.backend]))
|
|
718
775
|
};
|
|
719
|
-
|
|
776
|
+
logger2.info("Application scanned", { appName: scan.appName, haiModules: scan.haiModules, requiredServices: scan.requiredServices, serviceBackends: scan.serviceBackends });
|
|
720
777
|
return ok(scan);
|
|
721
778
|
} catch {
|
|
722
779
|
return err(HaiDeployError.SCAN_FAILED, deployM("deploy_scanFailed", { params: { error: "Invalid or ambiguous application/configuration/lockfile; specify the application directory and run pnpm install" } }));
|
|
723
780
|
}
|
|
724
781
|
}
|
|
782
|
+
var logger3 = core.logger.child({ module: "deploy", scope: "command" });
|
|
783
|
+
function maskSecrets(text, secrets) {
|
|
784
|
+
if (!secrets || secrets.length === 0)
|
|
785
|
+
return text;
|
|
786
|
+
let masked = text;
|
|
787
|
+
for (const secret of secrets) {
|
|
788
|
+
if (secret && secret.length > 0)
|
|
789
|
+
masked = masked.split(secret).join("***");
|
|
790
|
+
}
|
|
791
|
+
return masked;
|
|
792
|
+
}
|
|
793
|
+
function runCommand(command, args, options = {}) {
|
|
794
|
+
return new Promise((resolve6) => {
|
|
795
|
+
const child = spawn(command, args, {
|
|
796
|
+
windowsHide: true,
|
|
797
|
+
cwd: options.cwd,
|
|
798
|
+
env: options.env ? { ...process2.env, ...options.env } : process2.env,
|
|
799
|
+
shell: false
|
|
800
|
+
});
|
|
801
|
+
let stdout = "";
|
|
802
|
+
let stderr = "";
|
|
803
|
+
let settled = false;
|
|
804
|
+
let timer;
|
|
805
|
+
const finish = (result) => {
|
|
806
|
+
if (settled)
|
|
807
|
+
return;
|
|
808
|
+
settled = true;
|
|
809
|
+
if (timer)
|
|
810
|
+
clearTimeout(timer);
|
|
811
|
+
resolve6(result);
|
|
812
|
+
};
|
|
813
|
+
if (options.timeoutMs && options.timeoutMs > 0) {
|
|
814
|
+
timer = setTimeout(() => {
|
|
815
|
+
child.kill("SIGKILL");
|
|
816
|
+
logger3.error("Command timed out", { command, timeoutMs: options.timeoutMs });
|
|
817
|
+
finish(err(
|
|
818
|
+
HaiDeployError.REMOTE_COMMAND_FAILED,
|
|
819
|
+
deployM("deploy_commandTimeout", { params: { command } })
|
|
820
|
+
));
|
|
821
|
+
}, options.timeoutMs);
|
|
822
|
+
}
|
|
823
|
+
child.stdout.on("data", (chunk) => {
|
|
824
|
+
stdout += chunk.toString("utf-8");
|
|
825
|
+
options.onOutput?.(maskSecrets(chunk.toString("utf-8"), options.secrets));
|
|
826
|
+
});
|
|
827
|
+
child.stderr.on("data", (chunk) => {
|
|
828
|
+
stderr += chunk.toString("utf-8");
|
|
829
|
+
});
|
|
830
|
+
child.on("error", (error) => {
|
|
831
|
+
logger3.error("Command failed to start", { command, error: maskSecrets(String(error), options.secrets) });
|
|
832
|
+
finish(err(
|
|
833
|
+
HaiDeployError.REMOTE_COMMAND_FAILED,
|
|
834
|
+
deployM("deploy_commandFailed", {
|
|
835
|
+
params: { command, error: maskSecrets(error.message, options.secrets) }
|
|
836
|
+
}),
|
|
837
|
+
error
|
|
838
|
+
));
|
|
839
|
+
});
|
|
840
|
+
child.on("close", (code) => {
|
|
841
|
+
const exitCode = code ?? -1;
|
|
842
|
+
if (exitCode === 0) {
|
|
843
|
+
finish(ok({ code: exitCode, stdout, stderr }));
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
logger3.error("Command exited with non-zero code", {
|
|
847
|
+
command,
|
|
848
|
+
code: exitCode,
|
|
849
|
+
stderr: maskSecrets(stderr, options.secrets).slice(-500),
|
|
850
|
+
stdout: maskSecrets(stdout, options.secrets).slice(-3e3)
|
|
851
|
+
});
|
|
852
|
+
finish(err(
|
|
853
|
+
HaiDeployError.REMOTE_COMMAND_FAILED,
|
|
854
|
+
deployM("deploy_commandFailed", {
|
|
855
|
+
params: { command, error: maskSecrets(`${stdout.slice(-2500)}
|
|
856
|
+
${stderr || `exit ${exitCode}`}`, options.secrets).slice(-3e3) }
|
|
857
|
+
})
|
|
858
|
+
));
|
|
859
|
+
});
|
|
860
|
+
if (options.input !== void 0) {
|
|
861
|
+
child.stdin.write(options.input);
|
|
862
|
+
child.stdin.end();
|
|
863
|
+
} else {
|
|
864
|
+
child.stdin.end();
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// src/container/deploy-scripts.ts
|
|
870
|
+
function shellArgument(value) {
|
|
871
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
872
|
+
}
|
|
873
|
+
function createApplyScript(options) {
|
|
874
|
+
const values = {
|
|
875
|
+
ID: shellArgument(options.deploymentId),
|
|
876
|
+
PROJECT: shellArgument(`hai-${options.projectName}`),
|
|
877
|
+
ENV: shellArgument(options.environment),
|
|
878
|
+
RUNTIME: shellArgument(options.runtime),
|
|
879
|
+
PARTIAL: options.partial ? "true" : "false",
|
|
880
|
+
SELECTED: shellArgument(options.selected.join(" ")),
|
|
881
|
+
ROOT: shellArgument(options.directory),
|
|
882
|
+
PORT: String(options.hostPort),
|
|
883
|
+
HEALTH: options.healthPaths.map(shellArgument).join(" ")
|
|
884
|
+
};
|
|
885
|
+
return String.raw`#!/bin/sh
|
|
886
|
+
set -eu
|
|
887
|
+
umask 077
|
|
888
|
+
source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
889
|
+
destination=@@ROOT@@
|
|
890
|
+
if [ "$#" -gt 0 ]; then destination=$1; fi
|
|
891
|
+
root=$(mkdir -p -- "$destination" && CDPATH= cd -- "$destination" && pwd)
|
|
892
|
+
id=@@ID@@
|
|
893
|
+
project=@@PROJECT@@
|
|
894
|
+
export HAI_DEPLOY_ENVIRONMENT=@@ENV@@
|
|
895
|
+
runtime=@@RUNTIME@@
|
|
896
|
+
partial=@@PARTIAL@@
|
|
897
|
+
orphan_option=''
|
|
898
|
+
if [ "$partial" = false ]; then orphan_option=--remove-orphans; fi
|
|
899
|
+
recreate_option=--force-recreate
|
|
900
|
+
if [ "$partial" = true ]; then recreate_option=--no-recreate; fi
|
|
901
|
+
selected=@@SELECTED@@
|
|
902
|
+
release="$root/releases/$id"
|
|
903
|
+
case "$release/" in "$source_dir/"*) printf 'Extract the package outside the deployment directory before applying it.\n' >&2; exit 1;; esac
|
|
904
|
+
stage=''
|
|
905
|
+
previous=''
|
|
906
|
+
restore=''
|
|
907
|
+
changed=false
|
|
908
|
+
locked=false
|
|
909
|
+
started=$(date +%s)
|
|
910
|
+
step_started=$started
|
|
911
|
+
step() {
|
|
912
|
+
if [ -n "$stage" ]; then
|
|
913
|
+
duration=$(( $(date +%s) - step_started ))
|
|
914
|
+
printf '[PASS] %s (%ss)\n' "$stage" "$duration"
|
|
915
|
+
printf '{"stage":"%s","status":"passed","durationSeconds":%s}\n' "$stage" "$duration" >> "$root/steps-$id.jsonl"
|
|
916
|
+
fi
|
|
917
|
+
stage="$1"
|
|
918
|
+
step_started=$(date +%s)
|
|
919
|
+
printf '\n[%s] %s\n' "$stage" "$2"
|
|
920
|
+
}
|
|
921
|
+
compose() {
|
|
922
|
+
arguments_file="$release/compose-files"
|
|
923
|
+
if [ -n "$restore" ]; then arguments_file="$root/releases/$restore/compose-files"; fi
|
|
924
|
+
# -f 按顺序合并,由 Compose 保留未选服务、卷和环境文件,不自行拼装配置。
|
|
925
|
+
command_args="$release/.compose-command"
|
|
926
|
+
# 文件列表先反向读取,再向参数头部加入,保留原始合并顺序。
|
|
927
|
+
awk '{lines[NR]=$0} END {for(i=NR;i>0;i--) print lines[i]}' "$arguments_file" > "$command_args"
|
|
928
|
+
while IFS= read -r file; do set -- -f "$file" "$@"; done < "$command_args"
|
|
929
|
+
"$runtime" compose -p "$project" "$@"
|
|
930
|
+
}
|
|
931
|
+
remove_selected() {
|
|
932
|
+
test "$partial" = true || return 0
|
|
933
|
+
# 直接替换选中容器,避免 Compose Provider 连带重建依赖它的未选服务;不删除卷。
|
|
934
|
+
for service in "$@"; do
|
|
935
|
+
containers=$("$runtime" ps -aq --filter "label=com.docker.compose.project=$project" --filter "label=com.docker.compose.service=$service") || return 1
|
|
936
|
+
for container in $containers; do
|
|
937
|
+
"$runtime" rm -f "$container" || return 1
|
|
938
|
+
done
|
|
939
|
+
done
|
|
940
|
+
}
|
|
941
|
+
finish() {
|
|
942
|
+
code=$?
|
|
943
|
+
trap - EXIT HUP INT TERM
|
|
944
|
+
if [ "$code" -ne 0 ]; then
|
|
945
|
+
printf '[FAIL] %s (exit %s)\n' "$stage" "$code" >&2
|
|
946
|
+
if [ "$changed" = true ]; then
|
|
947
|
+
diagnostics="$root/diagnostics-$id.log"
|
|
948
|
+
: > "$diagnostics"
|
|
949
|
+
for service in $selected; do
|
|
950
|
+
containers=$("$runtime" ps -aq --filter "label=com.docker.compose.project=$project" --filter "label=com.docker.compose.service=$service" || true)
|
|
951
|
+
for container in $containers; do
|
|
952
|
+
"$runtime" inspect --format '{{.Name}} {{.State.Status}} exit={{.State.ExitCode}}' "$container" >> "$diagnostics" 2>&1 || true
|
|
953
|
+
"$runtime" logs --tail 80 "$container" >> "$diagnostics" 2>&1 || true
|
|
954
|
+
done
|
|
955
|
+
done
|
|
956
|
+
printf 'Startup diagnostics saved to %s (private server file).\n' "$diagnostics" >&2
|
|
957
|
+
fi
|
|
958
|
+
if [ -f "$release/rollback-images" ]; then
|
|
959
|
+
while IFS=' ' read -r image reference; do
|
|
960
|
+
test -n "$reference" || continue
|
|
961
|
+
"$runtime" tag "$image" "$reference" || printf '[FAIL] Cannot restore image %s\n' "$reference" >&2
|
|
962
|
+
done < "$release/rollback-images"
|
|
963
|
+
fi
|
|
964
|
+
if [ "$changed" = true ] && [ -n "$previous" ]; then
|
|
965
|
+
printf '[RECOVER] Restoring previous selected services\n' >&2
|
|
966
|
+
recover_selected=$selected
|
|
967
|
+
if [ "$partial" = false ]; then recover_selected=$(grep -v -- '-init$' "$root/releases/$previous/services"); fi
|
|
968
|
+
restore="$previous"
|
|
969
|
+
if remove_selected $recover_selected && compose up -d --no-deps --no-build --pull never "$recreate_option" $orphan_option $recover_selected; then
|
|
970
|
+
case " $selected " in *' app '*)
|
|
971
|
+
if grep -qx web "$root/releases/$previous/services"; then compose exec -T web nginx -s reload || true; fi
|
|
972
|
+
;;
|
|
973
|
+
esac
|
|
974
|
+
printf '[RECOVER] Previous containers restored; verify application health\n' >&2
|
|
975
|
+
else
|
|
976
|
+
printf '[FAIL] Recovery failed. Use the previous release and inspect container logs.\n' >&2
|
|
977
|
+
fi
|
|
978
|
+
elif [ "$changed" = true ]; then
|
|
979
|
+
compose stop $selected || true
|
|
980
|
+
printf '[RECOVER] Failed first installation stopped; data volumes retained\n' >&2
|
|
981
|
+
fi
|
|
982
|
+
fi
|
|
983
|
+
if [ "$locked" = true ]; then
|
|
984
|
+
elapsed=$(( $(date +%s) - started ))
|
|
985
|
+
outcome=passed
|
|
986
|
+
if [ "$code" -ne 0 ]; then outcome=failed; fi
|
|
987
|
+
printf '{"stage":"%s","status":"%s","durationSeconds":%s}\n' "$stage" "$outcome" "$(( $(date +%s) - step_started ))" >> "$root/steps-$id.jsonl"
|
|
988
|
+
printf '{"deploymentId":"%s","stage":"%s","exitCode":%s,"durationSeconds":%s}\n' "$id" "$stage" "$code" "$elapsed" > "$root/result-$id.json"
|
|
989
|
+
rmdir "$root/.deploy-lock" || true
|
|
990
|
+
fi
|
|
991
|
+
exit "$code"
|
|
992
|
+
}
|
|
993
|
+
trap finish EXIT
|
|
994
|
+
trap 'exit 130' HUP INT TERM
|
|
995
|
+
step preflight 'Checking deployment tools and package'
|
|
996
|
+
for tool in sha256sum tar awk curl; do command -v "$tool" >/dev/null || { printf 'Required command missing: %s\n' "$tool" >&2; exit 1; }; done
|
|
997
|
+
if [ "$runtime" = auto ]; then
|
|
998
|
+
if command -v podman >/dev/null 2>&1 && podman version >/dev/null 2>&1; then runtime=podman; else runtime=docker; fi
|
|
999
|
+
fi
|
|
1000
|
+
case "$runtime" in docker|podman) ;; *) printf 'Runtime must be docker or podman\n' >&2; exit 1;; esac
|
|
1001
|
+
"$runtime" version >/dev/null
|
|
1002
|
+
"$runtime" compose version >/dev/null
|
|
1003
|
+
"$runtime" compose up --help | grep -q never || { printf 'Compose is too old. Install Docker Compose v2 or podman-compose 1.6.0 or newer (supports --pull never).\n' >&2; exit 1; }
|
|
1004
|
+
mkdir "$root/.deploy-lock" 2>/dev/null || { printf 'Another deployment is running. Check %s/.deploy-lock before retrying.\n' "$root" >&2; exit 1; }
|
|
1005
|
+
locked=true
|
|
1006
|
+
cd "$source_dir"
|
|
1007
|
+
sha256sum -c SHA256SUMS
|
|
1008
|
+
if [ -f "$root/current" ]; then
|
|
1009
|
+
previous=$(cat "$root/current")
|
|
1010
|
+
case "$previous" in ''|*[!a-zA-Z0-9._-]*) printf 'Invalid deployment state\n' >&2; exit 1;; esac
|
|
1011
|
+
test -f "$root/releases/$previous/compose-files"
|
|
1012
|
+
fi
|
|
1013
|
+
if [ "$partial" = true ]; then
|
|
1014
|
+
test -n "$previous" || { printf 'Partial deployment requires a successful full installation. Deploy a complete bundle first.\n' >&2; exit 1; }
|
|
1015
|
+
cmp -s layout "$root/releases/$previous/layout" || { printf 'Ports, API routes or application layout changed. Create a full deployment bundle.\n' >&2; exit 1; }
|
|
1016
|
+
fi
|
|
1017
|
+
test ! -e "$release" || { printf 'This deployment ID was already used. Create a new deployment bundle.\n' >&2; exit 1; }
|
|
1018
|
+
mkdir -p "$release"
|
|
1019
|
+
cp -R "$source_dir/." "$release/"
|
|
1020
|
+
# Nginx 以非 root 用户运行;只开放不含凭据的代理配置读取权限。
|
|
1021
|
+
if [ -f "$release/web.conf" ]; then chmod 644 "$release/web.conf"; fi
|
|
1022
|
+
: > "$release/compose-files"
|
|
1023
|
+
if [ "$partial" = true ]; then
|
|
1024
|
+
cp "$root/releases/$previous/compose-files" "$release/compose-files"
|
|
1025
|
+
cp "$root/releases/$previous/middleware.env" "$release/middleware.env"
|
|
1026
|
+
for service in $selected; do
|
|
1027
|
+
grep -qx "$service" "$root/releases/$previous/services" || { printf 'Service %s has no installed baseline. Use a full deployment.\n' "$service" >&2; exit 1; }
|
|
1028
|
+
done
|
|
1029
|
+
fi
|
|
1030
|
+
printf '%s\n' "$release/compose-entry.json" >> "$release/compose-files"
|
|
1031
|
+
compose config >/dev/null
|
|
1032
|
+
compose config --services > "$release/services"
|
|
1033
|
+
: > "$release/rollback-images"
|
|
1034
|
+
if [ -n "$previous" ]; then
|
|
1035
|
+
for service in $selected; do
|
|
1036
|
+
containers=$("$runtime" ps -aq --filter "label=com.docker.compose.project=$project" --filter "label=com.docker.compose.service=$service")
|
|
1037
|
+
for container in $containers; do
|
|
1038
|
+
"$runtime" inspect --format '{{.Image}} {{.Config.Image}}' "$container" >> "$release/rollback-images"
|
|
1039
|
+
done
|
|
1040
|
+
done
|
|
1041
|
+
fi
|
|
1042
|
+
step images 'Loading packaged images (no registry access)'
|
|
1043
|
+
while IFS=' ' read -r archive reference; do
|
|
1044
|
+
test -n "$archive" || continue
|
|
1045
|
+
"$runtime" load -i "$release/images/$archive"
|
|
1046
|
+
"$runtime" image inspect "$reference" >/dev/null
|
|
1047
|
+
architecture=$("$runtime" image inspect --format '{{.Architecture}}' "$reference")
|
|
1048
|
+
host_arch=$(uname -m)
|
|
1049
|
+
case "$host_arch:$architecture" in x86_64:amd64|aarch64:arm64) ;; *) printf 'Image architecture %s does not match server %s\n' "$architecture" "$host_arch" >&2; exit 1;; esac
|
|
1050
|
+
done < "$release/image-list"
|
|
1051
|
+
step dependencies 'Checking dependencies of selected services'
|
|
1052
|
+
if [ "$partial" = true ]; then cat "$release/services" >> "$release/dependencies"; fi
|
|
1053
|
+
while IFS= read -r dependency; do
|
|
1054
|
+
test -n "$dependency" || continue
|
|
1055
|
+
case "$dependency" in *-init) continue;; esac
|
|
1056
|
+
case " $selected " in *" $dependency "*) continue;; esac
|
|
1057
|
+
running=$("$runtime" ps -q --filter "label=com.docker.compose.project=$project" --filter "label=com.docker.compose.service=$dependency")
|
|
1058
|
+
test -n "$running" || { printf 'Dependency %s is not running. Include it in a full deployment.\n' "$dependency" >&2; exit 1; }
|
|
1059
|
+
for container in $running; do
|
|
1060
|
+
test "$("$runtime" inspect --format '{{.State.Running}}' "$container")" = true || { printf 'Dependency %s is stopped. Start it before retrying.\n' "$dependency" >&2; exit 1; }
|
|
1061
|
+
done
|
|
1062
|
+
done < "$release/dependencies"
|
|
1063
|
+
step start 'Updating selected services'
|
|
1064
|
+
changed=true
|
|
1065
|
+
remove_selected $selected
|
|
1066
|
+
if [ -f "$release/initialize-storage" ]; then
|
|
1067
|
+
remove_selected storage-init
|
|
1068
|
+
compose up -d --no-deps --no-build --pull never storage
|
|
1069
|
+
compose up --no-deps --no-build --pull never "$recreate_option" --exit-code-from storage-init storage-init
|
|
1070
|
+
fi
|
|
1071
|
+
compose up -d --no-deps --no-build --pull never "$recreate_option" $orphan_option $selected
|
|
1072
|
+
case " $selected " in
|
|
1073
|
+
*' app '*)
|
|
1074
|
+
case " $selected " in *' web '*) ;; *)
|
|
1075
|
+
if grep -qx web "$release/services"; then compose exec -T web nginx -s reload; fi
|
|
1076
|
+
esac
|
|
1077
|
+
;;
|
|
1078
|
+
esac
|
|
1079
|
+
step health 'Checking HTTP access'
|
|
1080
|
+
for path in @@HEALTH@@; do
|
|
1081
|
+
ready=false
|
|
1082
|
+
attempt=0
|
|
1083
|
+
while [ "$attempt" -lt 40 ]; do
|
|
1084
|
+
if curl --noproxy '*' -fsS --max-time 5 "http://127.0.0.1:@@PORT@@$path" >/dev/null 2>&1; then ready=true; break; fi
|
|
1085
|
+
attempt=$((attempt + 1))
|
|
1086
|
+
sleep 3
|
|
1087
|
+
done
|
|
1088
|
+
test "$ready" = true || { printf 'HTTP check failed: %s. Inspect compose logs for selected services.\n' "$path" >&2; exit 1; }
|
|
1089
|
+
done
|
|
1090
|
+
step complete 'Deployment passed all checks'
|
|
1091
|
+
printf '%s\n' "$id" > "$root/current.next"
|
|
1092
|
+
mv "$root/current.next" "$root/current"
|
|
1093
|
+
printf '[PASS] %s\n' "$id"
|
|
1094
|
+
`.replace(/@@([A-Z]+)@@/g, (_match, name) => values[name]);
|
|
1095
|
+
}
|
|
1096
|
+
function createUploadScripts(config, bundle, projectName) {
|
|
1097
|
+
const ssh = config.ssh;
|
|
1098
|
+
const host = ssh?.host ?? "SERVER_HOST";
|
|
1099
|
+
const user = ssh?.username ?? "deploy";
|
|
1100
|
+
const remote = `${config.remote.baseDir}/${projectName}`;
|
|
1101
|
+
const port = ssh?.port ?? 22;
|
|
1102
|
+
const sh = String.raw`#!/bin/sh
|
|
1103
|
+
set -eu
|
|
1104
|
+
cd "$(dirname "$0")"
|
|
1105
|
+
host=@@HOST@@
|
|
1106
|
+
user=@@USER@@
|
|
1107
|
+
port=@@PORT@@
|
|
1108
|
+
remote=@@REMOTE@@
|
|
1109
|
+
bundle=@@BUNDLE@@
|
|
1110
|
+
if [ "$#" -ge 1 ]; then host=$1; fi
|
|
1111
|
+
if [ "$#" -ge 2 ]; then port=$2; fi
|
|
1112
|
+
if [ "$#" -ge 3 ]; then user=$3; fi
|
|
1113
|
+
identity=$(printenv HAI_DEPLOY_IDENTITY_FILE || true)
|
|
1114
|
+
if [ -z "$identity" ]; then identity=@@IDENTITY@@; fi
|
|
1115
|
+
test "$host" != SERVER_HOST || { printf 'Specify the server: sh deploy.sh SERVER [PORT] [USER].\n' >&2; exit 1; }
|
|
1116
|
+
for tool in ssh scp; do command -v "$tool" >/dev/null || { printf 'Install OpenSSH (%s).\n' "$tool" >&2; exit 1; }; done
|
|
1117
|
+
set -- -o BatchMode=yes -o StrictHostKeyChecking=@@STRICT@@ -o ConnectTimeout=10
|
|
1118
|
+
if [ -n "$identity" ]; then set -- "$@" -i "$identity"; fi
|
|
1119
|
+
known_hosts=@@KNOWN@@
|
|
1120
|
+
if value=$(printenv HAI_DEPLOY_KNOWN_HOSTS_FILE); then known_hosts=$value; fi
|
|
1121
|
+
if [ -n "$known_hosts" ]; then set -- "$@" -o "UserKnownHostsFile=$known_hosts"; fi
|
|
1122
|
+
printf '[1/3] Connecting to %s\n' "$host"
|
|
1123
|
+
ssh "$@" -p "$port" "$user@$host" "mkdir -p '$remote/incoming' && chmod 700 '$remote' '$remote/incoming'"
|
|
1124
|
+
printf '[2/3] Uploading %s\n' "$bundle"
|
|
1125
|
+
scp_host=$host
|
|
1126
|
+
case "$host" in *:*) scp_host="[$host]";; esac
|
|
1127
|
+
scp "$@" -P "$port" "$bundle" "$user@$scp_host:$remote/incoming/$bundle"
|
|
1128
|
+
printf '[3/3] Applying package and checking HTTP access\n'
|
|
1129
|
+
ssh "$@" -p "$port" "$user@$host" "cd '$remote/incoming' && mkdir -p '$bundle.contents' && tar -xf '$bundle' -C '$bundle.contents' && sh '$bundle.contents/apply.sh' '$remote'"
|
|
1130
|
+
`;
|
|
1131
|
+
const ps1 = String.raw`param([string]$IdentityFile = $env:HAI_DEPLOY_IDENTITY_FILE)
|
|
1132
|
+
$ErrorActionPreference = 'Stop'
|
|
1133
|
+
Set-Location -LiteralPath $PSScriptRoot
|
|
1134
|
+
$target = Get-Content -LiteralPath './deploy-target.json' -Raw | ConvertFrom-Json
|
|
1135
|
+
if ($target.host -eq 'SERVER_HOST') { throw 'Set the server host in deploy-target.json.' }
|
|
1136
|
+
if ($target.host -notmatch '^[a-zA-Z0-9][a-zA-Z0-9.:-]*$' -or $target.username -notmatch '^[a-zA-Z_][a-zA-Z0-9_-]*$' -or $target.directory -notmatch '^/[a-zA-Z0-9/_. -]+$' -or $target.directory -match '(^|/)\.\.(/|$)' -or $target.bundle -notmatch '^[a-zA-Z0-9_.+-]+\.tar$') { throw 'Invalid deployment target.' }
|
|
1137
|
+
$options = @('-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=10')
|
|
1138
|
+
if ($target.strictHostKeyChecking -eq $false) { $options[3] = 'StrictHostKeyChecking=no' }
|
|
1139
|
+
if (-not $IdentityFile) { $IdentityFile = $target.identityFile }
|
|
1140
|
+
if ($IdentityFile) { $options += @('-i', $IdentityFile) }
|
|
1141
|
+
if ($target.knownHostsFile) { $options += @('-o', "UserKnownHostsFile=$($target.knownHostsFile)") }
|
|
1142
|
+
$destination = "$($target.username)@$($target.host)"
|
|
1143
|
+
$scpDestination = if ($target.host.Contains(':')) { "$($target.username)@[$($target.host)]" } else { $destination }
|
|
1144
|
+
$remote = $target.directory
|
|
1145
|
+
$bundle = $target.bundle
|
|
1146
|
+
function Invoke-Step([string]$Name, [string]$Command, [string[]]$Arguments) {
|
|
1147
|
+
Write-Host "[$Name]"
|
|
1148
|
+
& $Command @Arguments
|
|
1149
|
+
if ($LASTEXITCODE -ne 0) { throw "$Name failed (exit $LASTEXITCODE)." }
|
|
1150
|
+
}
|
|
1151
|
+
try {
|
|
1152
|
+
foreach ($tool in @('ssh', 'scp')) { Get-Command $tool -ErrorAction Stop | Out-Null }
|
|
1153
|
+
Invoke-Step '1/3 Connect' 'ssh' ($options + @('-p', [string]$target.port, $destination, "mkdir -p '$remote/incoming' && chmod 700 '$remote' '$remote/incoming'"))
|
|
1154
|
+
Invoke-Step '2/3 Upload' 'scp' ($options + @('-P', [string]$target.port, $bundle, "$($scpDestination):$remote/incoming/$bundle"))
|
|
1155
|
+
Invoke-Step '3/3 Apply and verify HTTP' 'ssh' ($options + @('-p', [string]$target.port, $destination, "cd '$remote/incoming' && mkdir -p '$bundle.contents' && tar -xf '$bundle' -C '$bundle.contents' && sh '$bundle.contents/apply.sh' '$remote'"))
|
|
1156
|
+
} catch { Write-Error $_; exit 1 }
|
|
1157
|
+
`;
|
|
1158
|
+
const values = { HOST: shellArgument(host), USER: shellArgument(user), PORT: String(port), REMOTE: shellArgument(remote), BUNDLE: shellArgument(bundle), IDENTITY: shellArgument(ssh?.identityFile ?? ""), KNOWN: shellArgument(ssh?.knownHostsFile ?? ""), STRICT: ssh?.strictHostKeyChecking === false ? "no" : "accept-new" };
|
|
1159
|
+
return { sh: sh.replace(/@@([A-Z]+)@@/g, (_match, name) => values[name]), ps1 };
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// src/container/deploy-bundle.ts
|
|
1163
|
+
async function createDeploymentBundle(options) {
|
|
1164
|
+
const { directory, artifactDir, artifact, projectName, environment, config, selected } = options;
|
|
1165
|
+
const stem = basename(options.output).replace(/\.tar$/i, "");
|
|
1166
|
+
const suffix = `-${artifact.appVersion}`;
|
|
1167
|
+
const output = resolve(dirname(options.output), `${stem.endsWith(suffix) ? stem : `${stem}${suffix}`}.tar`);
|
|
1168
|
+
if (!/^[\w.+-]+\.tar$/.test(basename(output)))
|
|
1169
|
+
throw new Error(deployM("deploy_bundleNameInvalid"));
|
|
1170
|
+
const staging = mkdtempSync(join(directory, ".bundle-"));
|
|
1171
|
+
const temporaryTar = `${output}.partial`;
|
|
1172
|
+
mkdirSync(dirname(output), { recursive: true, mode: 448 });
|
|
1173
|
+
chmodSync(staging, 448);
|
|
1174
|
+
const files = [];
|
|
1175
|
+
const put = (file, content) => {
|
|
1176
|
+
mkdirSync(dirname(join(staging, file)), { recursive: true });
|
|
1177
|
+
writeFileSync(join(staging, file), content, { mode: 384 });
|
|
1178
|
+
files.push(file);
|
|
1179
|
+
};
|
|
1180
|
+
try {
|
|
1181
|
+
mkdirSync(join(staging, "images"));
|
|
1182
|
+
const compose = parse(readFileSync(join(directory, `docker-compose-${artifact.appVersion}.yml`), "utf8"));
|
|
1183
|
+
const dependencies = /* @__PURE__ */ new Set();
|
|
1184
|
+
const references = /* @__PURE__ */ new Set();
|
|
1185
|
+
for (const name of selected) {
|
|
1186
|
+
const service = compose.services[name];
|
|
1187
|
+
if (!service)
|
|
1188
|
+
throw new Error(deployM("deploy_selectionInvalid", { params: { service: name } }));
|
|
1189
|
+
if (typeof service.image === "string")
|
|
1190
|
+
references.add(service.image);
|
|
1191
|
+
if (service.depends_on) {
|
|
1192
|
+
for (const dependency of Array.isArray(service.depends_on) ? service.depends_on : Object.keys(service.depends_on))
|
|
1193
|
+
dependencies.add(dependency);
|
|
1194
|
+
}
|
|
1195
|
+
if (service.env_file)
|
|
1196
|
+
service.env_file = [`../${options.deploymentId}/.env.runtime`, `../${options.deploymentId}/middleware.env`];
|
|
1197
|
+
if (Array.isArray(service.volumes))
|
|
1198
|
+
service.volumes = service.volumes.map((mount) => typeof mount === "string" ? mount.replace("./web.conf:", `../${options.deploymentId}/web.conf:`) : mount);
|
|
1199
|
+
}
|
|
1200
|
+
put("compose.json", JSON.stringify({ ...compose, services: Object.fromEntries(selected.map((name) => [name, compose.services[name]])) }));
|
|
1201
|
+
put("compose-entry.json", JSON.stringify({ ...compose, services: Object.fromEntries(selected.map((name) => [name, { extends: { file: `../${options.deploymentId}/compose.json`, service: name } }])) }));
|
|
1202
|
+
let runtime;
|
|
1203
|
+
const imageList = [];
|
|
1204
|
+
for (const reference of references) {
|
|
1205
|
+
if (!/:[^/:]+$/.test(reference) || /:latest$/i.test(reference))
|
|
1206
|
+
throw new Error(deployM("deploy_imageVersionRequired", { params: { image: reference } }));
|
|
1207
|
+
const built = artifact.images.find((image) => image.imageRef === reference);
|
|
1208
|
+
const file = built?.file ?? `middleware-${imageList.length + 1}.tar`;
|
|
1209
|
+
if (built) {
|
|
1210
|
+
copyFileSync(join(artifactDir, file), join(staging, "images", file));
|
|
1211
|
+
} else {
|
|
1212
|
+
if (!runtime) {
|
|
1213
|
+
const candidates = options.runtime === "auto" ? ["podman", "docker"] : [options.runtime];
|
|
1214
|
+
for (const candidate of candidates) {
|
|
1215
|
+
if ((await runCommand(candidate, ["version"])).success) {
|
|
1216
|
+
runtime = candidate;
|
|
1217
|
+
break;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
if (!runtime)
|
|
1221
|
+
throw new Error(deployM("deploy_bundleRuntimeRequired"));
|
|
1222
|
+
}
|
|
1223
|
+
if (!(await runCommand(runtime, ["image", "inspect", reference])).success) {
|
|
1224
|
+
const pulled = await runCommand(runtime, ["pull", reference], { timeoutMs: 6e5 });
|
|
1225
|
+
if (!pulled.success)
|
|
1226
|
+
throw new Error(pulled.error.message);
|
|
1227
|
+
}
|
|
1228
|
+
const saved = await runCommand(runtime, ["save", ...runtime === "podman" ? ["--format", "docker-archive"] : [], "-o", join(staging, "images", file), reference], { timeoutMs: 6e5 });
|
|
1229
|
+
if (!saved.success)
|
|
1230
|
+
throw new Error(saved.error.message);
|
|
1231
|
+
}
|
|
1232
|
+
files.push(`images/${file}`);
|
|
1233
|
+
imageList.push(`${file} ${reference}`);
|
|
1234
|
+
}
|
|
1235
|
+
put(".env.runtime", readFileSync(join(directory, ".env.runtime"), "utf8"));
|
|
1236
|
+
put("middleware.env", options.middlewareEnv);
|
|
1237
|
+
if (selected.includes("web"))
|
|
1238
|
+
put("web.conf", readFileSync(join(directory, "web.conf"), "utf8"));
|
|
1239
|
+
put("manifest.json", `${JSON.stringify({ ...artifact, images: artifact.images.filter((image) => references.has(image.imageRef)), deploymentId: options.deploymentId, selectedServices: selected, partial: options.partial }, null, 2)}
|
|
1240
|
+
`);
|
|
1241
|
+
put("layout", `${config.expose.hostPort}:${options.containerPort}:${options.hasWeb}:${options.hasWeb ? await artifactChecksum(join(directory, "web.conf")) : ""}
|
|
1242
|
+
`);
|
|
1243
|
+
put("image-list", `${imageList.join("\n")}
|
|
1244
|
+
`);
|
|
1245
|
+
put("dependencies", `${[...dependencies].join("\n")}
|
|
1246
|
+
`);
|
|
1247
|
+
if (selected.includes("storage-init"))
|
|
1248
|
+
put("initialize-storage", "");
|
|
1249
|
+
put("apply.sh", createApplyScript({ deploymentId: options.deploymentId, projectName, environment, directory: `${config.remote.baseDir}/${projectName}`, runtime: config.remote.runtime, partial: options.partial, selected: selected.filter((name) => name !== "storage-init"), hostPort: config.expose.hostPort, healthPaths: options.healthPaths }));
|
|
1250
|
+
const instructions = deployM("deploy_bundleInstructions", { params: { file: basename(output), start: "apply.sh", images: [...references].join("\n"), compose: "compose.json" } });
|
|
1251
|
+
put("README.txt", `${deployM("deploy_bundleMetadata", { params: { version: artifact.appVersion, buildId: artifact.buildId, compose: "compose.json" } })}
|
|
1252
|
+
${instructions}
|
|
1253
|
+
`);
|
|
1254
|
+
const checksums = [];
|
|
1255
|
+
for (const file of files)
|
|
1256
|
+
checksums.push(`${await artifactChecksum(join(staging, file))} ${file}`);
|
|
1257
|
+
writeFileSync(join(staging, "SHA256SUMS"), `${checksums.join("\n")}
|
|
1258
|
+
`);
|
|
1259
|
+
const tar = await runCommand("tar", ["-cf", temporaryTar, "-C", staging, "."]);
|
|
1260
|
+
if (!tar.success)
|
|
1261
|
+
throw new Error(tar.error.message);
|
|
1262
|
+
chmodSync(temporaryTar, 384);
|
|
1263
|
+
renameSync(temporaryTar, output);
|
|
1264
|
+
const upload = createUploadScripts(config, basename(output), projectName);
|
|
1265
|
+
const scripts = `${output.slice(0, -4)}-deploy`;
|
|
1266
|
+
mkdirSync(scripts, { recursive: true, mode: 448 });
|
|
1267
|
+
writeFileSync(join(scripts, "deploy.sh"), upload.sh.replace('cd "$(dirname "$0")"', 'cd "$(dirname "$0")/.."'), { mode: 448 });
|
|
1268
|
+
writeFileSync(join(scripts, "deploy.ps1"), upload.ps1.replace("Set-Location -LiteralPath $PSScriptRoot", "Set-Location -LiteralPath (Split-Path -Parent $PSScriptRoot)").replace("'./deploy-target.json'", "(Join-Path $PSScriptRoot 'deploy-target.json')"));
|
|
1269
|
+
writeFileSync(join(scripts, "deploy-target.json"), `${JSON.stringify({ ...config.ssh, host: config.ssh?.host ?? "SERVER_HOST", username: config.ssh?.username ?? "deploy", port: config.ssh?.port ?? 22, directory: `${config.remote.baseDir}/${projectName}`, bundle: basename(output) }, null, 2)}
|
|
1270
|
+
`);
|
|
1271
|
+
writeFileSync(join(scripts, "README.txt"), instructions);
|
|
1272
|
+
return { bundlePath: output, instructions: `${instructions}
|
|
1273
|
+
${scripts}`, startName: "apply.sh" };
|
|
1274
|
+
} finally {
|
|
1275
|
+
rmSync(staging, { recursive: true, force: true });
|
|
1276
|
+
rmSync(temporaryTar, { force: true });
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
725
1279
|
var IMAGES = {
|
|
726
|
-
postgres: "postgres:16-alpine",
|
|
727
|
-
mysql: "mysql:8.4",
|
|
728
|
-
redis: "redis:7-alpine",
|
|
1280
|
+
postgres: "docker.io/library/postgres:16.15-alpine",
|
|
1281
|
+
mysql: "docker.io/library/mysql:8.4.11",
|
|
1282
|
+
redis: "docker.io/library/redis:7.4.11-alpine",
|
|
729
1283
|
rustfs: "ghcr.io/rustfs/rustfs:1.0.1",
|
|
730
1284
|
minio: "ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z",
|
|
731
|
-
qdrant: "qdrant/qdrant:v1.
|
|
732
|
-
pgvector: "pgvector/pgvector:pg17",
|
|
733
|
-
chroma: "chromadb/chroma:1.5.5",
|
|
1285
|
+
qdrant: "docker.io/qdrant/qdrant:v1.19.2",
|
|
1286
|
+
pgvector: "docker.io/pgvector/pgvector:0.8.0-pg17",
|
|
1287
|
+
chroma: "docker.io/chromadb/chroma:1.5.5",
|
|
734
1288
|
ldap: "docker.io/osixia/openldap:1.5.0",
|
|
735
|
-
smtp: "axllent/mailpit:v1.27.4"
|
|
1289
|
+
smtp: "docker.io/axllent/mailpit:v1.27.4"
|
|
736
1290
|
};
|
|
737
1291
|
function generatePassword() {
|
|
738
1292
|
return randomBytes(24).toString("base64url");
|
|
@@ -741,8 +1295,12 @@ function resolveSidecarSecrets(appDir, projectName) {
|
|
|
741
1295
|
const secretFile = join(appDir, ".hai", "deploy", "runtime", `${projectName}.secrets.json`);
|
|
742
1296
|
if (existsSync(secretFile)) {
|
|
743
1297
|
try {
|
|
744
|
-
|
|
1298
|
+
const saved = JSON.parse(readFileSync(secretFile, "utf-8"));
|
|
1299
|
+
if (!saved || !["dbPassword", "storagePassword", "ldapPassword"].every((key) => typeof saved[key] === "string" && saved[key].length > 0))
|
|
1300
|
+
throw new Error("Invalid credentials");
|
|
1301
|
+
return saved;
|
|
745
1302
|
} catch {
|
|
1303
|
+
throw new Error(deployM("deploy_secretsInvalid", { params: { path: secretFile } }));
|
|
746
1304
|
}
|
|
747
1305
|
}
|
|
748
1306
|
const secrets = {
|
|
@@ -910,6 +1468,28 @@ function planChromaVecdb(plan, projectName, net) {
|
|
|
910
1468
|
plan.env.HAI_VECDB = JSON.stringify({ type: "chroma", url: "http://vecdb:8000" });
|
|
911
1469
|
}
|
|
912
1470
|
function planSidecars(appDir, projectName, scan, provisionEnv, options, s3Engine = "rustfs") {
|
|
1471
|
+
const serviceModes = { ...options?.serviceModes };
|
|
1472
|
+
for (const requirement of scan.middlewares ?? []) {
|
|
1473
|
+
if (options?.selectedServices && !options.selectedServices.includes(requirement.service))
|
|
1474
|
+
continue;
|
|
1475
|
+
const mode = serviceModes[requirement.envPrefix] ?? serviceModes[requirement.service];
|
|
1476
|
+
if (mode !== "keep")
|
|
1477
|
+
continue;
|
|
1478
|
+
const endpoint = requirement.config.url ?? requirement.config.endpoint;
|
|
1479
|
+
let host = requirement.config.host;
|
|
1480
|
+
if (typeof endpoint === "string") {
|
|
1481
|
+
try {
|
|
1482
|
+
host = new URL(endpoint).hostname;
|
|
1483
|
+
} catch {
|
|
1484
|
+
continue;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
if (host === requirement.service || ["sqlite", "local", "memory", "lancedb"].includes(requirement.backend)) {
|
|
1488
|
+
serviceModes[requirement.envPrefix] = "default";
|
|
1489
|
+
serviceModes[requirement.service] = "default";
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
options = { ...options, serviceModes };
|
|
913
1493
|
const plan = {
|
|
914
1494
|
services: [],
|
|
915
1495
|
composeServices: {},
|
|
@@ -995,6 +1575,7 @@ function generateComposeFile(options) {
|
|
|
995
1575
|
const { projectName, imageRef, hostPort, containerPort, sidecar } = options;
|
|
996
1576
|
const app = {
|
|
997
1577
|
image: imageRef,
|
|
1578
|
+
labels: { "org.opencontainers.image.version": options.appVersion },
|
|
998
1579
|
container_name: `hai-${projectName}-app`,
|
|
999
1580
|
restart: "unless-stopped",
|
|
1000
1581
|
// 镜像已由 load 注入本地,禁止 compose 再从 registry 拉取
|
|
@@ -1008,41 +1589,76 @@ function generateComposeFile(options) {
|
|
|
1008
1589
|
if (sidecar.dependsOn.length > 0)
|
|
1009
1590
|
app.depends_on = sidecar.dependsOn;
|
|
1010
1591
|
const services = { app, ...sidecar.composeServices };
|
|
1592
|
+
if (options.web) {
|
|
1593
|
+
delete app.ports;
|
|
1594
|
+
services.web = {
|
|
1595
|
+
image: options.web.imageRef,
|
|
1596
|
+
labels: { "org.opencontainers.image.version": options.appVersion },
|
|
1597
|
+
container_name: `hai-${projectName}-web`,
|
|
1598
|
+
restart: "unless-stopped",
|
|
1599
|
+
pull_policy: "never",
|
|
1600
|
+
ports: [`${hostPort}:${options.web.containerPort}`],
|
|
1601
|
+
environment: { PUBLIC_API_BASE: "" },
|
|
1602
|
+
healthcheck: { test: ["CMD", "wget", "-Y", "off", "-q", "-O", "/dev/null", `http://127.0.0.1:${options.web.containerPort}/_hai/web-health`], interval: "30s", timeout: "5s", retries: 3 },
|
|
1603
|
+
volumes: ["./web.conf:/etc/nginx/conf.d/default.conf:ro"],
|
|
1604
|
+
depends_on: ["app"],
|
|
1605
|
+
networks: ["hai-app"]
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1011
1608
|
if (services["storage-init"])
|
|
1012
1609
|
services["storage-init"] = { ...services["storage-init"], image: imageRef };
|
|
1013
1610
|
const compose = {
|
|
1611
|
+
"x-hai-version": options.appVersion,
|
|
1014
1612
|
services,
|
|
1015
|
-
networks: {
|
|
1613
|
+
"networks": {
|
|
1016
1614
|
"hai-app": { name: `hai-${projectName}` }
|
|
1017
1615
|
}
|
|
1018
1616
|
};
|
|
1019
1617
|
if (Object.keys(sidecar.volumes).length > 0)
|
|
1020
1618
|
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
1619
|
if (services["storage-init"]) {
|
|
1039
1620
|
app.depends_on = {
|
|
1040
1621
|
...Object.fromEntries((app.depends_on ?? []).map((name) => [name, { condition: "service_started" }])),
|
|
1041
1622
|
"storage-init": { condition: "service_completed_successfully" }
|
|
1042
1623
|
};
|
|
1043
1624
|
}
|
|
1625
|
+
for (const service of Object.values(services))
|
|
1626
|
+
service.pull_policy = "never";
|
|
1044
1627
|
return stringify(compose);
|
|
1045
1628
|
}
|
|
1629
|
+
function generateWebProxyConfig(port, apiPrefix, webPort = 8080) {
|
|
1630
|
+
if (!/^\/[\w/-]+$/.test(apiPrefix) || ![port, webPort].every((value) => Number.isInteger(value) && value >= 1 && value <= 65535))
|
|
1631
|
+
throw new Error("Invalid web proxy target");
|
|
1632
|
+
return `server {
|
|
1633
|
+
listen ${webPort};
|
|
1634
|
+
server_name _;
|
|
1635
|
+
root /usr/share/nginx/html;
|
|
1636
|
+
location / {
|
|
1637
|
+
try_files $uri $uri/ /index.html;
|
|
1638
|
+
}
|
|
1639
|
+
location = /runtime-config.js {
|
|
1640
|
+
add_header Cache-Control "no-store";
|
|
1641
|
+
default_type application/javascript;
|
|
1642
|
+
return 200 'globalThis.__HAI_PUBLIC_CONFIG__={apiBase:globalThis.location.origin+"${apiPrefix}"};';
|
|
1643
|
+
}
|
|
1644
|
+
location = /_hai/web-health {
|
|
1645
|
+
access_log off;
|
|
1646
|
+
return 200 'ok';
|
|
1647
|
+
}
|
|
1648
|
+
location = /health {
|
|
1649
|
+
proxy_pass http://app:${port};
|
|
1650
|
+
proxy_set_header Host $http_host;
|
|
1651
|
+
}
|
|
1652
|
+
location ${apiPrefix.replace(/\/$/, "")}/ {
|
|
1653
|
+
proxy_pass http://app:${port};
|
|
1654
|
+
proxy_http_version 1.1;
|
|
1655
|
+
proxy_set_header Host $http_host;
|
|
1656
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
1657
|
+
proxy_buffering off;
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
`;
|
|
1661
|
+
}
|
|
1046
1662
|
function serializeEnvFile(env) {
|
|
1047
1663
|
return `${Object.entries(env).map(([key, value]) => {
|
|
1048
1664
|
let literal = value;
|
|
@@ -1058,194 +1674,7 @@ function serializeEnvFile(env) {
|
|
|
1058
1674
|
}).join("\n")}
|
|
1059
1675
|
`;
|
|
1060
1676
|
}
|
|
1061
|
-
|
|
1062
|
-
return `**/node_modules
|
|
1063
|
-
**/dist
|
|
1064
|
-
**/build
|
|
1065
|
-
**/.turbo
|
|
1066
|
-
**/.svelte-kit
|
|
1067
|
-
**/coverage
|
|
1068
|
-
**/test-results
|
|
1069
|
-
**/playwright-report
|
|
1070
|
-
**/.hai/deploy
|
|
1071
|
-
**/.env.local
|
|
1072
|
-
**/.env.*.local
|
|
1073
|
-
**/.env.runtime
|
|
1074
|
-
.git
|
|
1075
|
-
`;
|
|
1076
|
-
}
|
|
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";
|
|
1082
|
-
return `# syntax=docker/dockerfile:1.7
|
|
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
|
|
1086
|
-
ENV PNPM_HOME=/pnpm
|
|
1087
|
-
ENV PATH=$PNPM_HOME:$PATH
|
|
1088
|
-
ENV CI=true
|
|
1089
|
-
ENV HAI_DEPLOY_ENVIRONMENT=${environment}
|
|
1090
|
-
ENV HAI_CORE_ENV=${coreEnv}
|
|
1091
|
-
WORKDIR /workspace
|
|
1092
|
-
RUN corepack enable && corepack prepare pnpm@12.11.2 --activate
|
|
1093
|
-
COPY . .
|
|
1094
|
-
RUN pnpm install --frozen-lockfile --ignore-scripts
|
|
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
|
|
1097
|
-
RUN pnpm pm deploy --filter ${packageName} --prod /prod/app
|
|
1098
|
-
|
|
1099
|
-
FROM node:24-bookworm-slim AS runtime
|
|
1100
|
-
ENV NODE_ENV=production
|
|
1101
|
-
ENV HAI_DEPLOY_ENVIRONMENT=${environment}
|
|
1102
|
-
ENV HAI_CORE_ENV=${coreEnv}
|
|
1103
|
-
ENV HOST=0.0.0.0
|
|
1104
|
-
ENV PORT=${containerPort}
|
|
1105
|
-
WORKDIR /app
|
|
1106
|
-
COPY --from=build --chown=node:node /prod/app ./
|
|
1107
|
-
RUN mkdir -p data && chown node:node data
|
|
1108
|
-
USER node
|
|
1109
|
-
EXPOSE ${containerPort}
|
|
1110
|
-
CMD ["node", ${JSON.stringify(options.entrypoint ?? "build")}]
|
|
1111
|
-
`;
|
|
1112
|
-
}
|
|
1113
|
-
var logger4 = core.logger.child({ module: "deploy", scope: "command" });
|
|
1114
|
-
function maskSecrets(text, secrets) {
|
|
1115
|
-
if (!secrets || secrets.length === 0)
|
|
1116
|
-
return text;
|
|
1117
|
-
let masked = text;
|
|
1118
|
-
for (const secret of secrets) {
|
|
1119
|
-
if (secret && secret.length > 0)
|
|
1120
|
-
masked = masked.split(secret).join("***");
|
|
1121
|
-
}
|
|
1122
|
-
return masked;
|
|
1123
|
-
}
|
|
1124
|
-
function runCommand(command, args, options = {}) {
|
|
1125
|
-
return new Promise((resolve3) => {
|
|
1126
|
-
const child = spawn(command, args, {
|
|
1127
|
-
cwd: options.cwd,
|
|
1128
|
-
env: options.env ? { ...process2.env, ...options.env } : process2.env,
|
|
1129
|
-
shell: false
|
|
1130
|
-
});
|
|
1131
|
-
let stdout = "";
|
|
1132
|
-
let stderr = "";
|
|
1133
|
-
let settled = false;
|
|
1134
|
-
let timer;
|
|
1135
|
-
const finish = (result) => {
|
|
1136
|
-
if (settled)
|
|
1137
|
-
return;
|
|
1138
|
-
settled = true;
|
|
1139
|
-
if (timer)
|
|
1140
|
-
clearTimeout(timer);
|
|
1141
|
-
resolve3(result);
|
|
1142
|
-
};
|
|
1143
|
-
if (options.timeoutMs && options.timeoutMs > 0) {
|
|
1144
|
-
timer = setTimeout(() => {
|
|
1145
|
-
child.kill("SIGKILL");
|
|
1146
|
-
logger4.error("Command timed out", { command, timeoutMs: options.timeoutMs });
|
|
1147
|
-
finish(err(
|
|
1148
|
-
HaiDeployError.REMOTE_COMMAND_FAILED,
|
|
1149
|
-
deployM("deploy_commandTimeout", { params: { command } })
|
|
1150
|
-
));
|
|
1151
|
-
}, options.timeoutMs);
|
|
1152
|
-
}
|
|
1153
|
-
child.stdout.on("data", (chunk) => {
|
|
1154
|
-
stdout += chunk.toString("utf-8");
|
|
1155
|
-
});
|
|
1156
|
-
child.stderr.on("data", (chunk) => {
|
|
1157
|
-
stderr += chunk.toString("utf-8");
|
|
1158
|
-
});
|
|
1159
|
-
child.on("error", (error) => {
|
|
1160
|
-
logger4.error("Command failed to start", { command, error: maskSecrets(String(error), options.secrets) });
|
|
1161
|
-
finish(err(
|
|
1162
|
-
HaiDeployError.REMOTE_COMMAND_FAILED,
|
|
1163
|
-
deployM("deploy_commandFailed", {
|
|
1164
|
-
params: { command, error: maskSecrets(error.message, options.secrets) }
|
|
1165
|
-
}),
|
|
1166
|
-
error
|
|
1167
|
-
));
|
|
1168
|
-
});
|
|
1169
|
-
child.on("close", (code) => {
|
|
1170
|
-
const exitCode = code ?? -1;
|
|
1171
|
-
if (exitCode === 0) {
|
|
1172
|
-
finish(ok({ code: exitCode, stdout, stderr }));
|
|
1173
|
-
return;
|
|
1174
|
-
}
|
|
1175
|
-
logger4.error("Command exited with non-zero code", {
|
|
1176
|
-
command,
|
|
1177
|
-
code: exitCode,
|
|
1178
|
-
stderr: maskSecrets(stderr, options.secrets).slice(-500),
|
|
1179
|
-
stdout: maskSecrets(stdout, options.secrets).slice(-3e3)
|
|
1180
|
-
});
|
|
1181
|
-
finish(err(
|
|
1182
|
-
HaiDeployError.REMOTE_COMMAND_FAILED,
|
|
1183
|
-
deployM("deploy_commandFailed", {
|
|
1184
|
-
params: { command, error: maskSecrets(`${stdout.slice(-2500)}
|
|
1185
|
-
${stderr || `exit ${exitCode}`}`, options.secrets).slice(-3e3) }
|
|
1186
|
-
})
|
|
1187
|
-
));
|
|
1188
|
-
});
|
|
1189
|
-
if (options.input !== void 0) {
|
|
1190
|
-
child.stdin.write(options.input);
|
|
1191
|
-
child.stdin.end();
|
|
1192
|
-
} else {
|
|
1193
|
-
child.stdin.end();
|
|
1194
|
-
}
|
|
1195
|
-
});
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
// src/container/deploy-container-runtime.ts
|
|
1199
|
-
var logger5 = core.logger.child({ module: "deploy", scope: "container-runtime" });
|
|
1200
|
-
async function detectLocalRuntime(preferred = "auto") {
|
|
1201
|
-
const candidates = preferred === "auto" ? ["podman", "docker"] : [preferred];
|
|
1202
|
-
for (const runtime of candidates) {
|
|
1203
|
-
const probe = await runCommand(runtime, ["version"], { timeoutMs: 15e3 });
|
|
1204
|
-
if (probe.success) {
|
|
1205
|
-
logger5.info("Local container runtime detected", { runtime });
|
|
1206
|
-
return ok(runtime);
|
|
1207
|
-
}
|
|
1208
|
-
}
|
|
1209
|
-
return err(
|
|
1210
|
-
HaiDeployError.CONTAINER_RUNTIME_NOT_FOUND,
|
|
1211
|
-
deployM("deploy_containerRuntimeNotFound", { params: { runtime: candidates.join(" / ") } })
|
|
1212
|
-
);
|
|
1213
|
-
}
|
|
1214
|
-
async function buildImage(runtime, options) {
|
|
1215
|
-
const args = ["build", "-f", options.dockerfilePath, "-t", options.imageRef];
|
|
1216
|
-
if (runtime === "podman")
|
|
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}`);
|
|
1224
|
-
args.push(options.contextDir);
|
|
1225
|
-
logger5.info("Building container image", { runtime, imageRef: options.imageRef });
|
|
1226
|
-
const result = await runCommand(runtime, args, { cwd: options.contextDir });
|
|
1227
|
-
if (!result.success) {
|
|
1228
|
-
return err(
|
|
1229
|
-
HaiDeployError.CONTAINER_BUILD_FAILED,
|
|
1230
|
-
deployM("deploy_containerBuildFailed", { params: { error: result.error.message } }),
|
|
1231
|
-
result.error
|
|
1232
|
-
);
|
|
1233
|
-
}
|
|
1234
|
-
return ok(void 0);
|
|
1235
|
-
}
|
|
1236
|
-
async function saveImage(runtime, imageRef, tarPath) {
|
|
1237
|
-
logger5.info("Saving container image", { runtime, imageRef, tarPath });
|
|
1238
|
-
const result = await runCommand(runtime, ["save", "-o", tarPath, imageRef], {});
|
|
1239
|
-
if (!result.success) {
|
|
1240
|
-
return err(
|
|
1241
|
-
HaiDeployError.CONTAINER_BUILD_FAILED,
|
|
1242
|
-
deployM("deploy_containerBuildFailed", { params: { error: result.error.message } }),
|
|
1243
|
-
result.error
|
|
1244
|
-
);
|
|
1245
|
-
}
|
|
1246
|
-
return ok(void 0);
|
|
1247
|
-
}
|
|
1248
|
-
var logger6 = core.logger.child({ module: "deploy", scope: "ssh" });
|
|
1677
|
+
var logger4 = core.logger.child({ module: "deploy", scope: "ssh" });
|
|
1249
1678
|
function buildConnectOptions(config) {
|
|
1250
1679
|
const args = ["-o", "BatchMode=yes"];
|
|
1251
1680
|
args.push("-o", `ConnectTimeout=${config.connectTimeoutSeconds}`);
|
|
@@ -1273,14 +1702,15 @@ function buildScpUploadArgs(config, localPath, remotePath) {
|
|
|
1273
1702
|
"-P",
|
|
1274
1703
|
String(config.port),
|
|
1275
1704
|
localPath,
|
|
1276
|
-
`${config.username}@${config.host}:${remotePath}`
|
|
1705
|
+
`${config.username}@${config.host.includes(":") ? `[${config.host}]` : config.host}:${remotePath}`
|
|
1277
1706
|
];
|
|
1278
1707
|
}
|
|
1279
1708
|
function createSshExecutor(config) {
|
|
1280
1709
|
const exec = async (remoteCommand, options) => {
|
|
1281
1710
|
return runCommand("ssh", buildSshExecArgs(config, remoteCommand), {
|
|
1282
1711
|
timeoutMs: options?.timeoutMs,
|
|
1283
|
-
secrets: options?.secrets
|
|
1712
|
+
secrets: options?.secrets,
|
|
1713
|
+
onOutput: options?.onOutput
|
|
1284
1714
|
});
|
|
1285
1715
|
};
|
|
1286
1716
|
const upload = async (localPath, remotePath) => {
|
|
@@ -1296,7 +1726,7 @@ function createSshExecutor(config) {
|
|
|
1296
1726
|
return ok(void 0);
|
|
1297
1727
|
};
|
|
1298
1728
|
const ping = async () => {
|
|
1299
|
-
|
|
1729
|
+
logger4.debug("SSH preflight", { host: config.host, port: config.port });
|
|
1300
1730
|
const result = await exec("true", { timeoutMs: 2e4 });
|
|
1301
1731
|
if (!result.success) {
|
|
1302
1732
|
return err(
|
|
@@ -1311,52 +1741,24 @@ function createSshExecutor(config) {
|
|
|
1311
1741
|
}
|
|
1312
1742
|
|
|
1313
1743
|
// src/providers/deploy-provider-docker-ssh.ts
|
|
1314
|
-
var
|
|
1744
|
+
var logger5 = core.logger.child({ module: "deploy", scope: "provider-docker-ssh" });
|
|
1315
1745
|
var HEALTH_POLL_INTERVAL = 3e3;
|
|
1316
1746
|
var HEALTH_MAX_ATTEMPTS = 40;
|
|
1317
|
-
function shellArgument(value) {
|
|
1318
|
-
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
1319
|
-
}
|
|
1320
1747
|
function makeDeploymentId() {
|
|
1321
1748
|
const now = /* @__PURE__ */ new Date();
|
|
1322
1749
|
const pad = (n) => String(n).padStart(2, "0");
|
|
1323
1750
|
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
1324
1751
|
return `${stamp}-${randomBytes(3).toString("hex")}`;
|
|
1325
1752
|
}
|
|
1326
|
-
function parseLoadedImageRef(stdout, fallback) {
|
|
1327
|
-
const match = stdout.match(/Loaded image(?:\(s\))?:\s*(\S+)/);
|
|
1328
|
-
return match ? match[1] : fallback;
|
|
1329
|
-
}
|
|
1330
1753
|
function sleep(ms) {
|
|
1331
|
-
return new Promise((
|
|
1332
|
-
}
|
|
1333
|
-
function readPackageName(appDir) {
|
|
1334
|
-
try {
|
|
1335
|
-
const pkg = JSON.parse(readFileSync(join(appDir, "package.json"), "utf-8"));
|
|
1336
|
-
return pkg.name ?? "app";
|
|
1337
|
-
} catch {
|
|
1338
|
-
return "app";
|
|
1339
|
-
}
|
|
1340
|
-
}
|
|
1341
|
-
function resolveWorkspaceRoot(appDir) {
|
|
1342
|
-
if (existsSync(join(appDir, "pnpm-workspace.yaml")))
|
|
1343
|
-
return appDir;
|
|
1344
|
-
let current = appDir;
|
|
1345
|
-
let parent = dirname(current);
|
|
1346
|
-
while (parent !== current) {
|
|
1347
|
-
if (existsSync(join(parent, "pnpm-workspace.yaml")))
|
|
1348
|
-
return parent;
|
|
1349
|
-
current = parent;
|
|
1350
|
-
parent = dirname(parent);
|
|
1351
|
-
}
|
|
1352
|
-
return appDir;
|
|
1754
|
+
return new Promise((resolve6) => setTimeout(resolve6, ms));
|
|
1353
1755
|
}
|
|
1354
1756
|
function createDockerSshProvider(config, container) {
|
|
1355
|
-
const
|
|
1356
|
-
const httpProxy = container?.httpProxy ?? true;
|
|
1357
|
-
const ssh = createSshExecutor(config.ssh);
|
|
1757
|
+
const ssh = config.ssh ? createSshExecutor(config.ssh) : void 0;
|
|
1358
1758
|
const baseDir = config.remote.baseDir;
|
|
1359
1759
|
async function detectRemoteRuntime() {
|
|
1760
|
+
if (!ssh)
|
|
1761
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_sshRequired"));
|
|
1360
1762
|
const candidates = config.remote.runtime === "auto" ? ["podman", "docker"] : [config.remote.runtime];
|
|
1361
1763
|
for (const runtime of candidates) {
|
|
1362
1764
|
const versionResult = await ssh.exec(`${runtime} version`, { timeoutMs: 2e4 });
|
|
@@ -1364,7 +1766,7 @@ function createDockerSshProvider(config, container) {
|
|
|
1364
1766
|
continue;
|
|
1365
1767
|
const composeResult = await ssh.exec(`${runtime} compose version`, { timeoutMs: 2e4 });
|
|
1366
1768
|
if (composeResult.success) {
|
|
1367
|
-
|
|
1769
|
+
logger5.info("Remote container runtime detected", { runtime });
|
|
1368
1770
|
return ok(runtime);
|
|
1369
1771
|
}
|
|
1370
1772
|
}
|
|
@@ -1388,32 +1790,44 @@ function createDockerSshProvider(config, container) {
|
|
|
1388
1790
|
const deploy2 = async (context) => {
|
|
1389
1791
|
const { appDir, projectName, scan, provisionEnv, appEnv, options } = context;
|
|
1390
1792
|
const deploymentId = makeDeploymentId();
|
|
1391
|
-
const containerPort = config.expose.containerPort;
|
|
1392
1793
|
const hostPort = config.expose.hostPort;
|
|
1393
|
-
const localImageTag = `hai-${projectName}:${deploymentId}`;
|
|
1394
|
-
const runtimeResult = await detectLocalRuntime(localRuntimePref);
|
|
1395
|
-
if (!runtimeResult.success)
|
|
1396
|
-
return err(runtimeResult.error);
|
|
1397
|
-
const localRuntime = runtimeResult.data;
|
|
1398
1794
|
const artifactDir = join(appDir, ".hai", "deploy", options?.environment ?? "prod");
|
|
1399
|
-
mkdirSync(artifactDir, { recursive: true });
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
logger7.info("Generated fallback Dockerfile", { dockerfilePath, contextDir });
|
|
1795
|
+
mkdirSync(artifactDir, { recursive: true, mode: 448 });
|
|
1796
|
+
chmodSync(artifactDir, 448);
|
|
1797
|
+
const artifact = context.artifact;
|
|
1798
|
+
const resolvedBuildDir = context.artifactDir;
|
|
1799
|
+
const images = artifact.images;
|
|
1800
|
+
const web = images.find((image) => image.name === "web");
|
|
1801
|
+
const app = images.find((image) => image.name === "app");
|
|
1802
|
+
if (options?.selectedServices) {
|
|
1803
|
+
const available = [...images.map((image) => image.name), ...scan.requiredServices];
|
|
1804
|
+
const invalid = options.selectedServices.find((name) => !available.includes(name));
|
|
1805
|
+
if (!options.selectedServices.length || new Set(options.selectedServices).size !== options.selectedServices.length || invalid)
|
|
1806
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_selectionInvalid", { params: { service: invalid ?? options.selectedServices.join(", ") } }));
|
|
1412
1807
|
}
|
|
1808
|
+
const hasWeb = artifact.hasWeb || !!web;
|
|
1809
|
+
const partial = artifact.partial || options?.selectedServices !== void 0;
|
|
1810
|
+
const containerPort = app?.containerPort ?? artifact.appContainerPort ?? config.expose.containerPort;
|
|
1811
|
+
const webContainerPort = web?.containerPort ?? artifact.webContainerPort ?? 8080;
|
|
1812
|
+
const host = config.ssh?.host;
|
|
1813
|
+
const url = host ? `http://${host.includes(":") ? `[${host}]` : host}:${hostPort}` : `http://SERVER_HOST:${hostPort}`;
|
|
1413
1814
|
const { env, sidecar } = buildRuntimeEnv(appDir, projectName, scan, provisionEnv, containerPort, options, appEnv, container?.s3Engine);
|
|
1815
|
+
if (sidecar.composeServices["storage-init"] && !app && options?.selectedServices?.includes("storage"))
|
|
1816
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_storageArtifactRequired"));
|
|
1817
|
+
let webProxy;
|
|
1818
|
+
if (hasWeb) {
|
|
1819
|
+
const server = loadDeploymentConfig("serv", join(appDir, "config", "_serv.yml"), env);
|
|
1820
|
+
if (!server.success)
|
|
1821
|
+
return server;
|
|
1822
|
+
const http = server.data.http;
|
|
1823
|
+
const cors = server.data.cors;
|
|
1824
|
+
const apiPrefix = http?.apiPrefix ?? "/api/v1";
|
|
1825
|
+
if (typeof apiPrefix !== "string" || !/^\/[\w/-]+$/.test(apiPrefix))
|
|
1826
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_webProxyInvalid"));
|
|
1827
|
+
webProxy = generateWebProxyConfig(containerPort, apiPrefix, webContainerPort);
|
|
1828
|
+
env.HAI_SERV = JSON.stringify({ ...server.data, cors: { ...cors, origin: (!cors?.origin || cors.origin === "*") && config.ssh ? url : cors?.origin ?? "*" } });
|
|
1829
|
+
}
|
|
1414
1830
|
const envPath = join(artifactDir, ".env.runtime");
|
|
1415
|
-
const previousEnv = existsSync(envPath) ? parseEnv(readFileSync(envPath, "utf8")) : {};
|
|
1416
|
-
Object.assign(env, { ...previousEnv, ...env });
|
|
1417
1831
|
writeFileSync(envPath, serializeEnvFile(env), { mode: 384 });
|
|
1418
1832
|
if (options?.environment) {
|
|
1419
1833
|
const environment = loadDeploymentEnvironment({ environment: options.environment, envDir: options.envDir ?? scan.envDir ?? appDir });
|
|
@@ -1423,88 +1837,83 @@ function createDockerSshProvider(config, container) {
|
|
|
1423
1837
|
if (!saved.success)
|
|
1424
1838
|
return saved;
|
|
1425
1839
|
}
|
|
1426
|
-
const
|
|
1427
|
-
contextDir,
|
|
1428
|
-
dockerfilePath,
|
|
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
|
|
1432
|
-
});
|
|
1433
|
-
if (!buildResult.success)
|
|
1434
|
-
return err(buildResult.error);
|
|
1435
|
-
const tarPath = join(artifactDir, `${projectName}.tar`);
|
|
1436
|
-
const saveResult = await saveImage(localRuntime, localImageTag, tarPath);
|
|
1437
|
-
if (!saveResult.success)
|
|
1438
|
-
return err(saveResult.error);
|
|
1439
|
-
const pingResult = await ssh.ping();
|
|
1440
|
-
if (!pingResult.success)
|
|
1441
|
-
return err(pingResult.error);
|
|
1442
|
-
const remoteRuntimeResult = await detectRemoteRuntime();
|
|
1443
|
-
if (!remoteRuntimeResult.success)
|
|
1444
|
-
return err(remoteRuntimeResult.error);
|
|
1445
|
-
const remoteRuntime = remoteRuntimeResult.data;
|
|
1446
|
-
const remoteDir = `${baseDir}/${projectName}`;
|
|
1447
|
-
const prepareResult = await ssh.exec(`mkdir -p -- ${shellArgument(remoteDir)} && chmod 700 -- ${shellArgument(remoteDir)}`);
|
|
1448
|
-
if (!prepareResult.success)
|
|
1449
|
-
return err(prepareResult.error);
|
|
1450
|
-
const remoteTar = `${remoteDir}/image.tar`;
|
|
1451
|
-
const uploadTarResult = await ssh.upload(tarPath, remoteTar);
|
|
1452
|
-
if (!uploadTarResult.success)
|
|
1453
|
-
return err(uploadTarResult.error);
|
|
1454
|
-
const loadResult = await ssh.exec(`${remoteRuntime} load -i ${shellArgument(remoteTar)}`, { timeoutMs: 3e5 });
|
|
1455
|
-
if (!loadResult.success)
|
|
1456
|
-
return err(loadResult.error);
|
|
1457
|
-
const loadedRef = parseLoadedImageRef(loadResult.data.stdout, localImageTag);
|
|
1458
|
-
const remoteImageRef = remoteRuntime === "podman" ? `localhost/${localImageTag}` : localImageTag;
|
|
1459
|
-
if (loadedRef !== remoteImageRef) {
|
|
1460
|
-
const tagResult = await ssh.exec(`${remoteRuntime} tag ${shellArgument(loadedRef)} ${shellArgument(remoteImageRef)}`);
|
|
1461
|
-
if (!tagResult.success)
|
|
1462
|
-
return err(tagResult.error);
|
|
1463
|
-
}
|
|
1840
|
+
const composeName = `docker-compose-${artifact.appVersion}.yml`;
|
|
1464
1841
|
const composeContent = generateComposeFile({
|
|
1465
1842
|
projectName,
|
|
1466
|
-
|
|
1843
|
+
appVersion: artifact.appVersion,
|
|
1844
|
+
imageRef: app?.imageRef ?? "localhost/hai-unselected:0",
|
|
1467
1845
|
hostPort,
|
|
1468
1846
|
containerPort,
|
|
1469
1847
|
sidecar,
|
|
1470
|
-
|
|
1471
|
-
previousCompose: existsSync(join(artifactDir, "compose.yml")) ? readFileSync(join(artifactDir, "compose.yml"), "utf8") : void 0
|
|
1848
|
+
web: hasWeb ? { imageRef: web?.imageRef ?? "localhost/hai-unselected:0", containerPort: webContainerPort } : void 0
|
|
1472
1849
|
});
|
|
1473
|
-
const composePath = join(artifactDir,
|
|
1850
|
+
const composePath = join(artifactDir, composeName);
|
|
1474
1851
|
writeFileSync(composePath, composeContent);
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1852
|
+
if (webProxy)
|
|
1853
|
+
writeFileSync(join(artifactDir, "web.conf"), webProxy);
|
|
1854
|
+
const selected = options?.selectedServices ? [...options.selectedServices] : artifact.partial ? images.map((image) => image.name) : [...images.map((image) => image.name), ...Object.keys(sidecar.composeServices)];
|
|
1855
|
+
if (!selected.length || selected.some((name) => !/^[a-z][a-z0-9-]*$/.test(name)) || new Set(selected).size !== selected.length)
|
|
1856
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_selectionInvalid", { params: { service: selected.join(", ") } }));
|
|
1857
|
+
for (const name of selected) {
|
|
1858
|
+
if (["app", "web"].includes(name) && !images.some((image) => image.name === name))
|
|
1859
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_selectionInvalid", { params: { service: name } }));
|
|
1860
|
+
}
|
|
1861
|
+
if (selected.includes("storage") && sidecar.composeServices["storage-init"] && !selected.includes("storage-init"))
|
|
1862
|
+
selected.push("storage-init");
|
|
1863
|
+
options?.onProgress?.("packaging");
|
|
1864
|
+
const bundle = await createDeploymentBundle({
|
|
1865
|
+
directory: artifactDir,
|
|
1866
|
+
artifactDir: resolvedBuildDir,
|
|
1867
|
+
artifact,
|
|
1868
|
+
projectName,
|
|
1869
|
+
environment: options?.environment ?? "prod",
|
|
1870
|
+
deploymentId,
|
|
1871
|
+
config,
|
|
1872
|
+
runtime: options?.runtime ?? "auto",
|
|
1873
|
+
selected,
|
|
1874
|
+
partial,
|
|
1875
|
+
hasWeb,
|
|
1876
|
+
containerPort,
|
|
1877
|
+
middlewareEnv: serializeEnvFile(sidecar.env),
|
|
1878
|
+
healthPaths: hasWeb ? ["/health", "/_hai/web-health", "/"] : [scan.isSvelteKit ? "/" : "/health"],
|
|
1879
|
+
output: options?.output ?? join(artifactDir, `bundle-${projectName}-${deploymentId}-${artifact.appVersion}.tar`)
|
|
1880
|
+
});
|
|
1881
|
+
if (options?.bundleOnly)
|
|
1882
|
+
return ok({ url: "", deploymentId, status: "packaged", provider: "docker-ssh", healthCheck: "skipped", envVarsSet: Object.keys(env), ...bundle });
|
|
1883
|
+
if (!ssh)
|
|
1884
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_sshRequired"));
|
|
1885
|
+
const ping = await ssh.ping();
|
|
1886
|
+
if (!ping.success)
|
|
1887
|
+
return ping;
|
|
1888
|
+
const remoteRuntime = await detectRemoteRuntime();
|
|
1889
|
+
if (!remoteRuntime.success)
|
|
1890
|
+
return remoteRuntime;
|
|
1891
|
+
const remoteDir = `${baseDir}/${projectName}`;
|
|
1892
|
+
const remoteTar = `${remoteDir}/incoming/${basename(bundle.bundlePath)}`;
|
|
1893
|
+
const prepared = await ssh.exec(`mkdir -p -- ${shellArgument(`${remoteDir}/incoming`)} && chmod 700 -- ${shellArgument(remoteDir)} ${shellArgument(`${remoteDir}/incoming`)}`);
|
|
1894
|
+
if (!prepared.success)
|
|
1895
|
+
return prepared;
|
|
1896
|
+
const uploaded = await ssh.upload(bundle.bundlePath, remoteTar);
|
|
1897
|
+
if (!uploaded.success)
|
|
1898
|
+
return uploaded;
|
|
1899
|
+
let pendingOutput = "";
|
|
1900
|
+
const started = await ssh.exec(
|
|
1901
|
+
`mkdir -p ${shellArgument(`${remoteTar}.contents`)} && chmod 600 ${shellArgument(remoteTar)} && tar -xf ${shellArgument(remoteTar)} -C ${shellArgument(`${remoteTar}.contents`)} && sh ${shellArgument(`${remoteTar}.contents/${bundle.startName}`)} ${shellArgument(remoteDir)}`,
|
|
1902
|
+
{ timeoutMs: 6e5, secrets: sidecar.secrets, onOutput: (output) => {
|
|
1903
|
+
pendingOutput += output;
|
|
1904
|
+
const lines = pendingOutput.split("\n");
|
|
1905
|
+
pendingOutput = lines.pop() ?? "";
|
|
1906
|
+
for (const line of lines) {
|
|
1907
|
+
const stage = /^\[(preflight|images|dependencies|start|health|complete)\]/.exec(line)?.[1];
|
|
1908
|
+
if (stage)
|
|
1909
|
+
options?.onProgress?.(stage);
|
|
1910
|
+
}
|
|
1911
|
+
} }
|
|
1501
1912
|
);
|
|
1502
|
-
if (!
|
|
1503
|
-
return
|
|
1504
|
-
|
|
1505
|
-
const
|
|
1506
|
-
logger7.info("Running health check", { url });
|
|
1507
|
-
const healthy = await healthCheck(`${url}${scan.isSvelteKit ? "/" : "/health"}`);
|
|
1913
|
+
if (!started.success)
|
|
1914
|
+
return started;
|
|
1915
|
+
logger5.info("Running health check", { url });
|
|
1916
|
+
const healthy = await healthCheck(`${url}${!hasWeb && scan.isSvelteKit ? "/" : "/health"}`) && (!hasWeb || await healthCheck(`${url}/_hai/web-health`) && await healthCheck(`${url}/`));
|
|
1508
1917
|
if (!healthy) {
|
|
1509
1918
|
return err(
|
|
1510
1919
|
HaiDeployError.HEALTH_CHECK_FAILED,
|
|
@@ -1517,14 +1926,24 @@ exec ${remoteRuntime} compose --env-file .env.runtime -p hai-${projectName} up -
|
|
|
1517
1926
|
status: "ready",
|
|
1518
1927
|
provider: "docker-ssh",
|
|
1519
1928
|
healthCheck: "passed",
|
|
1520
|
-
envVarsSet: Object.keys(env)
|
|
1929
|
+
envVarsSet: Object.keys(env),
|
|
1930
|
+
bundlePath: bundle.bundlePath
|
|
1521
1931
|
};
|
|
1522
|
-
|
|
1932
|
+
logger5.info("docker-ssh deployment complete", { url, deploymentId });
|
|
1523
1933
|
return ok(result);
|
|
1524
1934
|
};
|
|
1525
|
-
|
|
1935
|
+
const deploySafely = async (context) => {
|
|
1936
|
+
try {
|
|
1937
|
+
return await deploy2(context);
|
|
1938
|
+
} catch (error) {
|
|
1939
|
+
logger5.error("Deployment bundle failed", { error });
|
|
1940
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1941
|
+
return err(HaiDeployError.DEPLOY_FAILED, deployM("deploy_bundleFailed", { params: { reason: maskSecrets(reason, Object.values(context.appEnv ?? {}).filter((value) => value.length > 6)) } }), error);
|
|
1942
|
+
}
|
|
1943
|
+
};
|
|
1944
|
+
return { name: "docker-ssh", deploy: deploySafely };
|
|
1526
1945
|
}
|
|
1527
|
-
var
|
|
1946
|
+
var logger6 = core.logger.child({ module: "deploy", scope: "provider-vercel" });
|
|
1528
1947
|
var VERCEL_API = "https://api.vercel.com";
|
|
1529
1948
|
var POLL_INTERVAL = 3e3;
|
|
1530
1949
|
var MAX_POLL_ATTEMPTS = 60;
|
|
@@ -1577,7 +1996,7 @@ function createVercelProvider() {
|
|
|
1577
1996
|
return {
|
|
1578
1997
|
name: "vercel",
|
|
1579
1998
|
async authenticate(apiToken) {
|
|
1580
|
-
|
|
1999
|
+
logger6.debug("Authenticating with Vercel");
|
|
1581
2000
|
try {
|
|
1582
2001
|
const user = await vercelFetch(
|
|
1583
2002
|
apiToken,
|
|
@@ -1585,10 +2004,10 @@ function createVercelProvider() {
|
|
|
1585
2004
|
);
|
|
1586
2005
|
token = apiToken;
|
|
1587
2006
|
const username = user.user.username || user.user.email;
|
|
1588
|
-
|
|
2007
|
+
logger6.info("Vercel authenticated", { user: username });
|
|
1589
2008
|
return ok(username);
|
|
1590
2009
|
} catch (error) {
|
|
1591
|
-
|
|
2010
|
+
logger6.error("Vercel authentication failed", { error });
|
|
1592
2011
|
return err(toDeployError(HaiDeployError.AUTH_FAILED, "deploy_authFailed", error));
|
|
1593
2012
|
}
|
|
1594
2013
|
},
|
|
@@ -1599,11 +2018,11 @@ function createVercelProvider() {
|
|
|
1599
2018
|
deployM("deploy_authRequired")
|
|
1600
2019
|
);
|
|
1601
2020
|
}
|
|
1602
|
-
|
|
2021
|
+
logger6.debug("Creating Vercel project", { projectName });
|
|
1603
2022
|
try {
|
|
1604
2023
|
const existingId = await findExistingProject(token, projectName);
|
|
1605
2024
|
if (existingId !== null) {
|
|
1606
|
-
|
|
2025
|
+
logger6.info("Vercel project found", { projectName, projectId: existingId });
|
|
1607
2026
|
return ok(existingId);
|
|
1608
2027
|
}
|
|
1609
2028
|
try {
|
|
@@ -1618,13 +2037,13 @@ function createVercelProvider() {
|
|
|
1618
2037
|
})
|
|
1619
2038
|
}
|
|
1620
2039
|
);
|
|
1621
|
-
|
|
2040
|
+
logger6.info("Vercel project created", { projectName, projectId: project.id });
|
|
1622
2041
|
return ok(project.id);
|
|
1623
2042
|
} catch (error) {
|
|
1624
2043
|
if (error instanceof VercelApiError && error.status === 409) {
|
|
1625
2044
|
const concurrentProjectId = await findExistingProject(token, projectName);
|
|
1626
2045
|
if (concurrentProjectId !== null) {
|
|
1627
|
-
|
|
2046
|
+
logger6.info("Vercel project reused after create conflict", {
|
|
1628
2047
|
projectName,
|
|
1629
2048
|
projectId: concurrentProjectId
|
|
1630
2049
|
});
|
|
@@ -1634,7 +2053,7 @@ function createVercelProvider() {
|
|
|
1634
2053
|
throw error;
|
|
1635
2054
|
}
|
|
1636
2055
|
} catch (error) {
|
|
1637
|
-
|
|
2056
|
+
logger6.error("Vercel project creation failed", { projectName, error });
|
|
1638
2057
|
return err(toDeployError(HaiDeployError.PROJECT_CREATE_FAILED, "deploy_projectCreateFailed", error));
|
|
1639
2058
|
}
|
|
1640
2059
|
},
|
|
@@ -1645,7 +2064,7 @@ function createVercelProvider() {
|
|
|
1645
2064
|
deployM("deploy_authRequired")
|
|
1646
2065
|
);
|
|
1647
2066
|
}
|
|
1648
|
-
|
|
2067
|
+
logger6.debug("Setting Vercel env vars", { projectId, count: Object.keys(envVars).length });
|
|
1649
2068
|
try {
|
|
1650
2069
|
const envEntries = Object.entries(envVars).map(([key, value]) => ({
|
|
1651
2070
|
key,
|
|
@@ -1661,10 +2080,10 @@ function createVercelProvider() {
|
|
|
1661
2080
|
body: JSON.stringify(envEntries)
|
|
1662
2081
|
}
|
|
1663
2082
|
);
|
|
1664
|
-
|
|
2083
|
+
logger6.info("Vercel env vars set", { projectId, count: envEntries.length });
|
|
1665
2084
|
return ok(void 0);
|
|
1666
2085
|
} catch (error) {
|
|
1667
|
-
|
|
2086
|
+
logger6.error("Vercel env vars setup failed", { projectId, error });
|
|
1668
2087
|
return err(toDeployError(HaiDeployError.ENV_VAR_FAILED, "deploy_envVarFailed", error));
|
|
1669
2088
|
}
|
|
1670
2089
|
},
|
|
@@ -1675,8 +2094,11 @@ function createVercelProvider() {
|
|
|
1675
2094
|
deployM("deploy_authRequired")
|
|
1676
2095
|
);
|
|
1677
2096
|
}
|
|
1678
|
-
|
|
2097
|
+
logger6.debug("Deploying to Vercel", { projectId, outputDir });
|
|
1679
2098
|
try {
|
|
2099
|
+
const outputConfig = JSON.parse(readFileSync(join(outputDir, "config.json"), "utf8"));
|
|
2100
|
+
if (outputConfig.version !== 3)
|
|
2101
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_artifactInvalid"));
|
|
1680
2102
|
const filePaths = collectFiles(outputDir, outputDir);
|
|
1681
2103
|
const files = [];
|
|
1682
2104
|
for (const filePath of filePaths) {
|
|
@@ -1685,18 +2107,20 @@ function createVercelProvider() {
|
|
|
1685
2107
|
const sha = createHash("sha1").update(content).digest("hex");
|
|
1686
2108
|
const size = statSync(fullPath).size;
|
|
1687
2109
|
await uploadFile(token, content, sha);
|
|
1688
|
-
files.push({ file: filePath
|
|
2110
|
+
files.push({ file: `.vercel/output/${filePath}`, sha, size });
|
|
1689
2111
|
}
|
|
1690
|
-
|
|
2112
|
+
logger6.debug("Files uploaded", { count: files.length });
|
|
1691
2113
|
const deployment = await vercelFetch(
|
|
1692
2114
|
token,
|
|
1693
|
-
"/v13/deployments",
|
|
2115
|
+
"/v13/deployments?prebuilt=1",
|
|
1694
2116
|
{
|
|
1695
2117
|
method: "POST",
|
|
1696
2118
|
body: JSON.stringify({
|
|
1697
2119
|
name: projectId,
|
|
1698
2120
|
files,
|
|
1699
|
-
|
|
2121
|
+
version: 2,
|
|
2122
|
+
project: projectId,
|
|
2123
|
+
projectSettings: { framework: null, buildCommand: "", installCommand: "" }
|
|
1700
2124
|
})
|
|
1701
2125
|
}
|
|
1702
2126
|
);
|
|
@@ -1707,14 +2131,14 @@ function createVercelProvider() {
|
|
|
1707
2131
|
status: finalStatus,
|
|
1708
2132
|
envVarsSet: []
|
|
1709
2133
|
};
|
|
1710
|
-
|
|
2134
|
+
logger6.info("Vercel deployment complete", {
|
|
1711
2135
|
deploymentId: deployment.id,
|
|
1712
2136
|
url: result.url,
|
|
1713
2137
|
status: finalStatus
|
|
1714
2138
|
});
|
|
1715
2139
|
return ok(result);
|
|
1716
2140
|
} catch (error) {
|
|
1717
|
-
|
|
2141
|
+
logger6.error("Vercel deployment failed", { projectId, error });
|
|
1718
2142
|
return err(toDeployError(HaiDeployError.UPLOAD_FAILED, "deploy_uploadFailed", error));
|
|
1719
2143
|
}
|
|
1720
2144
|
}
|
|
@@ -1759,11 +2183,11 @@ async function pollDeploymentStatus(token, deploymentId) {
|
|
|
1759
2183
|
if (deployment.readyState === "ERROR" || deployment.readyState === "CANCELED") {
|
|
1760
2184
|
return "error";
|
|
1761
2185
|
}
|
|
1762
|
-
await new Promise((
|
|
2186
|
+
await new Promise((resolve6) => setTimeout(resolve6, POLL_INTERVAL));
|
|
1763
2187
|
}
|
|
1764
2188
|
return "building";
|
|
1765
2189
|
}
|
|
1766
|
-
var
|
|
2190
|
+
var logger7 = core.logger.child({ module: "deploy", scope: "provisioner-aliyun" });
|
|
1767
2191
|
function createAliyunProvisioner() {
|
|
1768
2192
|
let accessKey = null;
|
|
1769
2193
|
let secretKey = null;
|
|
@@ -1772,7 +2196,7 @@ function createAliyunProvisioner() {
|
|
|
1772
2196
|
name: "aliyun",
|
|
1773
2197
|
serviceType: "sms",
|
|
1774
2198
|
async authenticate(credentials) {
|
|
1775
|
-
|
|
2199
|
+
logger7.debug("Authenticating with Aliyun");
|
|
1776
2200
|
try {
|
|
1777
2201
|
const akId = credentials.accessKeyId ?? credentials.access_key_id ?? credentials.access_key ?? "";
|
|
1778
2202
|
const akSecret = credentials.accessKeySecret ?? credentials.access_key_secret ?? credentials.secret_key ?? "";
|
|
@@ -1782,10 +2206,10 @@ function createAliyunProvisioner() {
|
|
|
1782
2206
|
accessKey = akId;
|
|
1783
2207
|
secretKey = akSecret;
|
|
1784
2208
|
signName = credentials.signName;
|
|
1785
|
-
|
|
2209
|
+
logger7.info("Aliyun authenticated", { accessKeyId: `${akId.slice(0, 6)}***` });
|
|
1786
2210
|
return ok(akId);
|
|
1787
2211
|
} catch (error) {
|
|
1788
|
-
|
|
2212
|
+
logger7.error("Aliyun authentication failed", { error });
|
|
1789
2213
|
return err(
|
|
1790
2214
|
HaiDeployError.AUTH_FAILED,
|
|
1791
2215
|
deployM("deploy_authFailed", {
|
|
@@ -1802,7 +2226,7 @@ function createAliyunProvisioner() {
|
|
|
1802
2226
|
deployM("deploy_authRequired")
|
|
1803
2227
|
);
|
|
1804
2228
|
}
|
|
1805
|
-
|
|
2229
|
+
logger7.debug("Provisioning Aliyun SMS service");
|
|
1806
2230
|
return ok({
|
|
1807
2231
|
serviceType: "sms",
|
|
1808
2232
|
provisionerName: "aliyun",
|
|
@@ -1815,7 +2239,7 @@ function createAliyunProvisioner() {
|
|
|
1815
2239
|
}
|
|
1816
2240
|
};
|
|
1817
2241
|
}
|
|
1818
|
-
var
|
|
2242
|
+
var logger8 = core.logger.child({ module: "deploy", scope: "provisioner-neon" });
|
|
1819
2243
|
var NEON_API = "https://console.neon.tech/api/v2";
|
|
1820
2244
|
var DEFAULT_NEON_DATABASE_NAME = "neondb";
|
|
1821
2245
|
var DEFAULT_NEON_ROLE_NAME = "neondb_owner";
|
|
@@ -1874,7 +2298,7 @@ function createNeonProvisioner() {
|
|
|
1874
2298
|
name: "neon",
|
|
1875
2299
|
serviceType: "db",
|
|
1876
2300
|
async authenticate(credentials) {
|
|
1877
|
-
|
|
2301
|
+
logger8.debug("Authenticating with Neon");
|
|
1878
2302
|
try {
|
|
1879
2303
|
const apiToken = credentials.apiKey ?? credentials.token ?? credentials.api_key ?? "";
|
|
1880
2304
|
if (!apiToken) {
|
|
@@ -1882,10 +2306,10 @@ function createNeonProvisioner() {
|
|
|
1882
2306
|
}
|
|
1883
2307
|
await listNeonProjects(apiToken);
|
|
1884
2308
|
token = apiToken;
|
|
1885
|
-
|
|
2309
|
+
logger8.info("Neon authenticated");
|
|
1886
2310
|
return ok("neon-user");
|
|
1887
2311
|
} catch (error) {
|
|
1888
|
-
|
|
2312
|
+
logger8.error("Neon authentication failed", { error });
|
|
1889
2313
|
return err(
|
|
1890
2314
|
HaiDeployError.AUTH_FAILED,
|
|
1891
2315
|
deployM("deploy_authFailed", {
|
|
@@ -1902,7 +2326,7 @@ function createNeonProvisioner() {
|
|
|
1902
2326
|
deployM("deploy_authRequired")
|
|
1903
2327
|
);
|
|
1904
2328
|
}
|
|
1905
|
-
|
|
2329
|
+
logger8.debug("Provisioning Neon database", { appName });
|
|
1906
2330
|
let resource;
|
|
1907
2331
|
try {
|
|
1908
2332
|
const projectName = `${appName}-db`;
|
|
@@ -1910,7 +2334,7 @@ function createNeonProvisioner() {
|
|
|
1910
2334
|
if (existingProject !== null) {
|
|
1911
2335
|
resource = { resourceInfo: `neon-project:${existingProject.id}`, resourceStatus: "reused" };
|
|
1912
2336
|
const connectionUri2 = await getNeonConnectionUri(token, existingProject.id);
|
|
1913
|
-
|
|
2337
|
+
logger8.info("Neon project reused", {
|
|
1914
2338
|
projectId: existingProject.id,
|
|
1915
2339
|
projectName
|
|
1916
2340
|
});
|
|
@@ -1932,10 +2356,10 @@ function createNeonProvisioner() {
|
|
|
1932
2356
|
if (!projectId || !connectionUri) {
|
|
1933
2357
|
throw new Error(deployM("deploy_provisionNoResult", { params: { service: "Neon" } }));
|
|
1934
2358
|
}
|
|
1935
|
-
|
|
2359
|
+
logger8.info("Neon database provisioned", { projectId });
|
|
1936
2360
|
return ok(buildNeonProvisionResult(projectId, connectionUri, "created"));
|
|
1937
2361
|
} catch (error) {
|
|
1938
|
-
|
|
2362
|
+
logger8.error("Neon provisioning failed", { appName });
|
|
1939
2363
|
if (resource) {
|
|
1940
2364
|
return recoveryFailure({ ...HaiDeployError.PROVISION_FAILED}, {
|
|
1941
2365
|
projectName: appName,
|
|
@@ -1957,7 +2381,7 @@ function createNeonProvisioner() {
|
|
|
1957
2381
|
}
|
|
1958
2382
|
};
|
|
1959
2383
|
}
|
|
1960
|
-
var
|
|
2384
|
+
var logger9 = core.logger.child({ module: "deploy", scope: "provisioner-r2" });
|
|
1961
2385
|
var CF_API = "https://api.cloudflare.com/client/v4";
|
|
1962
2386
|
function createR2Provisioner() {
|
|
1963
2387
|
let token = null;
|
|
@@ -1968,7 +2392,7 @@ function createR2Provisioner() {
|
|
|
1968
2392
|
name: "r2",
|
|
1969
2393
|
serviceType: "storage",
|
|
1970
2394
|
async authenticate(credentials) {
|
|
1971
|
-
|
|
2395
|
+
logger9.debug("Authenticating with Cloudflare");
|
|
1972
2396
|
try {
|
|
1973
2397
|
const acctId = credentials.accountId ?? credentials.account_id ?? "";
|
|
1974
2398
|
const apiTok = credentials.apiToken ?? credentials.api_token ?? credentials.token ?? "";
|
|
@@ -1985,10 +2409,10 @@ function createR2Provisioner() {
|
|
|
1985
2409
|
token = apiTok;
|
|
1986
2410
|
accessKey = credentials.accessKeyId;
|
|
1987
2411
|
secretKey = credentials.secretAccessKey;
|
|
1988
|
-
|
|
2412
|
+
logger9.info("Cloudflare R2 authenticated", { accountId: acctId });
|
|
1989
2413
|
return ok(acctId);
|
|
1990
2414
|
} catch (error) {
|
|
1991
|
-
|
|
2415
|
+
logger9.error("Cloudflare R2 authentication failed", { error });
|
|
1992
2416
|
return err(
|
|
1993
2417
|
HaiDeployError.AUTH_FAILED,
|
|
1994
2418
|
deployM("deploy_authFailed", {
|
|
@@ -2006,7 +2430,7 @@ function createR2Provisioner() {
|
|
|
2006
2430
|
);
|
|
2007
2431
|
}
|
|
2008
2432
|
const bucketName = `${appName}-storage`.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
2009
|
-
|
|
2433
|
+
logger9.debug("Provisioning R2 bucket", { appName, bucketName });
|
|
2010
2434
|
try {
|
|
2011
2435
|
const createRes = await fetch(
|
|
2012
2436
|
`${CF_API}/accounts/${accountId}/r2/buckets`,
|
|
@@ -2022,7 +2446,7 @@ function createR2Provisioner() {
|
|
|
2022
2446
|
if (!createRes.ok && createRes.status !== 409) {
|
|
2023
2447
|
throw new Error(deployM("deploy_apiError", { params: { service: "Cloudflare", status: String(createRes.status) } }));
|
|
2024
2448
|
}
|
|
2025
|
-
|
|
2449
|
+
logger9.info("R2 bucket provisioned", { bucketName });
|
|
2026
2450
|
return ok({
|
|
2027
2451
|
serviceType: "storage",
|
|
2028
2452
|
provisionerName: "r2",
|
|
@@ -2040,7 +2464,7 @@ function createR2Provisioner() {
|
|
|
2040
2464
|
resourceInfo: `r2-bucket:${bucketName}`
|
|
2041
2465
|
});
|
|
2042
2466
|
} catch (error) {
|
|
2043
|
-
|
|
2467
|
+
logger9.error("R2 provisioning failed", { appName, error });
|
|
2044
2468
|
return err(
|
|
2045
2469
|
HaiDeployError.PROVISION_FAILED,
|
|
2046
2470
|
deployM("deploy_provisionFailed", {
|
|
@@ -2055,7 +2479,7 @@ function createR2Provisioner() {
|
|
|
2055
2479
|
}
|
|
2056
2480
|
};
|
|
2057
2481
|
}
|
|
2058
|
-
var
|
|
2482
|
+
var logger10 = core.logger.child({ module: "deploy", scope: "provisioner-resend" });
|
|
2059
2483
|
var RESEND_API = "https://api.resend.com";
|
|
2060
2484
|
function createResendProvisioner() {
|
|
2061
2485
|
let token = null;
|
|
@@ -2064,7 +2488,7 @@ function createResendProvisioner() {
|
|
|
2064
2488
|
name: "resend",
|
|
2065
2489
|
serviceType: "email",
|
|
2066
2490
|
async authenticate(credentials) {
|
|
2067
|
-
|
|
2491
|
+
logger10.debug("Authenticating with Resend");
|
|
2068
2492
|
try {
|
|
2069
2493
|
const apiToken = credentials.apiKey ?? credentials.api_key ?? credentials.token ?? "";
|
|
2070
2494
|
if (!apiToken || !credentials.from) {
|
|
@@ -2078,10 +2502,10 @@ function createResendProvisioner() {
|
|
|
2078
2502
|
}
|
|
2079
2503
|
token = apiToken;
|
|
2080
2504
|
from = credentials.from;
|
|
2081
|
-
|
|
2505
|
+
logger10.info("Resend authenticated");
|
|
2082
2506
|
return ok("resend-user");
|
|
2083
2507
|
} catch (error) {
|
|
2084
|
-
|
|
2508
|
+
logger10.error("Resend authentication failed", { error });
|
|
2085
2509
|
return err(
|
|
2086
2510
|
HaiDeployError.AUTH_FAILED,
|
|
2087
2511
|
deployM("deploy_authFailed", {
|
|
@@ -2098,7 +2522,7 @@ function createResendProvisioner() {
|
|
|
2098
2522
|
deployM("deploy_authRequired")
|
|
2099
2523
|
);
|
|
2100
2524
|
}
|
|
2101
|
-
|
|
2525
|
+
logger10.debug("Provisioning Resend email service");
|
|
2102
2526
|
return ok({
|
|
2103
2527
|
serviceType: "email",
|
|
2104
2528
|
provisionerName: "resend",
|
|
@@ -2111,7 +2535,7 @@ function createResendProvisioner() {
|
|
|
2111
2535
|
}
|
|
2112
2536
|
};
|
|
2113
2537
|
}
|
|
2114
|
-
var
|
|
2538
|
+
var logger11 = core.logger.child({ module: "deploy", scope: "provisioner-upstash" });
|
|
2115
2539
|
var UPSTASH_API = "https://api.upstash.com";
|
|
2116
2540
|
function createUpstashAuthHeader(email, apiKey) {
|
|
2117
2541
|
return `Basic ${Buffer.from(`${email}:${apiKey}`).toString("base64")}`;
|
|
@@ -2162,7 +2586,7 @@ function createUpstashProvisioner() {
|
|
|
2162
2586
|
name: "upstash",
|
|
2163
2587
|
serviceType: "cache",
|
|
2164
2588
|
async authenticate(credentials) {
|
|
2165
|
-
|
|
2589
|
+
logger11.debug("Authenticating with Upstash");
|
|
2166
2590
|
try {
|
|
2167
2591
|
const userEmail = credentials.email ?? "";
|
|
2168
2592
|
const userKey = credentials.apiKey ?? credentials.api_key ?? credentials.token ?? "";
|
|
@@ -2172,10 +2596,10 @@ function createUpstashProvisioner() {
|
|
|
2172
2596
|
await listUpstashDatabases(userEmail, userKey);
|
|
2173
2597
|
email = userEmail;
|
|
2174
2598
|
apiKey = userKey;
|
|
2175
|
-
|
|
2599
|
+
logger11.info("Upstash authenticated", { email: userEmail });
|
|
2176
2600
|
return ok(userEmail);
|
|
2177
2601
|
} catch (error) {
|
|
2178
|
-
|
|
2602
|
+
logger11.error("Upstash authentication failed", { error });
|
|
2179
2603
|
return err(
|
|
2180
2604
|
HaiDeployError.AUTH_FAILED,
|
|
2181
2605
|
deployM("deploy_authFailed", {
|
|
@@ -2192,7 +2616,7 @@ function createUpstashProvisioner() {
|
|
|
2192
2616
|
deployM("deploy_authRequired")
|
|
2193
2617
|
);
|
|
2194
2618
|
}
|
|
2195
|
-
|
|
2619
|
+
logger11.debug("Provisioning Upstash Redis", { appName });
|
|
2196
2620
|
let resource;
|
|
2197
2621
|
try {
|
|
2198
2622
|
const databaseName = `${appName}-cache`;
|
|
@@ -2204,7 +2628,7 @@ function createUpstashProvisioner() {
|
|
|
2204
2628
|
}
|
|
2205
2629
|
resource = { resourceInfo: `upstash-db:${databaseId}`, resourceStatus: "reused" };
|
|
2206
2630
|
const detail = await getUpstashDatabase(email, apiKey, databaseId);
|
|
2207
|
-
|
|
2631
|
+
logger11.info("Upstash Redis reused", { databaseId, databaseName });
|
|
2208
2632
|
return ok(buildUpstashProvisionResult({ ...existingDatabase, ...detail }, "reused"));
|
|
2209
2633
|
}
|
|
2210
2634
|
const data = await upstashFetch(email, apiKey, "/v2/redis/database", {
|
|
@@ -2217,10 +2641,10 @@ function createUpstashProvisioner() {
|
|
|
2217
2641
|
});
|
|
2218
2642
|
if (data.database_id)
|
|
2219
2643
|
resource = { resourceInfo: `upstash-db:${data.database_id}`, resourceStatus: "created" };
|
|
2220
|
-
|
|
2644
|
+
logger11.info("Upstash Redis provisioned", { databaseId: data.database_id });
|
|
2221
2645
|
return ok(buildUpstashProvisionResult(data, "created"));
|
|
2222
2646
|
} catch (error) {
|
|
2223
|
-
|
|
2647
|
+
logger11.error("Upstash provisioning failed", { appName });
|
|
2224
2648
|
if (resource) {
|
|
2225
2649
|
return recoveryFailure({ ...HaiDeployError.PROVISION_FAILED}, {
|
|
2226
2650
|
projectName: appName,
|
|
@@ -2244,7 +2668,7 @@ function createUpstashProvisioner() {
|
|
|
2244
2668
|
}
|
|
2245
2669
|
|
|
2246
2670
|
// src/deploy-main.ts
|
|
2247
|
-
var
|
|
2671
|
+
var logger12 = core.logger.child({ module: "deploy", scope: "main" });
|
|
2248
2672
|
var currentConfig = null;
|
|
2249
2673
|
var currentProvider = null;
|
|
2250
2674
|
var currentContainerProvider = null;
|
|
@@ -2329,7 +2753,7 @@ var deploy = {
|
|
|
2329
2753
|
credentials: deployCredentials,
|
|
2330
2754
|
async init(config) {
|
|
2331
2755
|
if (initInProgress) {
|
|
2332
|
-
|
|
2756
|
+
logger12.warn("Deploy init already in progress, skipping concurrent call");
|
|
2333
2757
|
return err(
|
|
2334
2758
|
HaiDeployError.CONFIG_ERROR,
|
|
2335
2759
|
deployM("deploy_configError", { params: { error: "init already in progress" } })
|
|
@@ -2338,13 +2762,13 @@ var deploy = {
|
|
|
2338
2762
|
initInProgress = true;
|
|
2339
2763
|
try {
|
|
2340
2764
|
if (currentProvider || currentContainerProvider) {
|
|
2341
|
-
|
|
2765
|
+
logger12.warn("Deploy module is already initialized, reinitializing");
|
|
2342
2766
|
await deploy.close();
|
|
2343
2767
|
}
|
|
2344
|
-
|
|
2768
|
+
logger12.info("Initializing deploy module");
|
|
2345
2769
|
const parseResult = DeployConfigSchema.safeParse(config);
|
|
2346
2770
|
if (!parseResult.success) {
|
|
2347
|
-
|
|
2771
|
+
logger12.error("Deploy config validation failed", { error: parseResult.error.message });
|
|
2348
2772
|
return err(
|
|
2349
2773
|
HaiDeployError.CONFIG_ERROR,
|
|
2350
2774
|
deployM("deploy_configError", { params: { error: parseResult.error.message } }),
|
|
@@ -2357,14 +2781,14 @@ var deploy = {
|
|
|
2357
2781
|
let containerProvider = null;
|
|
2358
2782
|
if (parsed.provider.type === "docker-ssh") {
|
|
2359
2783
|
containerProvider = createDockerSshProvider(parsed.provider, parsed.container);
|
|
2360
|
-
|
|
2784
|
+
logger12.info("Container deploy provider ready", { provider: "docker-ssh", host: parsed.provider.ssh?.host });
|
|
2361
2785
|
} else {
|
|
2362
2786
|
provider = createProviderByType(parsed.provider.type);
|
|
2363
2787
|
const authResult = await provider.authenticate(parsed.provider.token);
|
|
2364
2788
|
if (!authResult.success) {
|
|
2365
2789
|
return err(authResult.error);
|
|
2366
2790
|
}
|
|
2367
|
-
|
|
2791
|
+
logger12.info("Provider authenticated", { provider: parsed.provider.type, user: authResult.data });
|
|
2368
2792
|
}
|
|
2369
2793
|
currentProvisioners.clear();
|
|
2370
2794
|
if (parsed.services) {
|
|
@@ -2376,14 +2800,14 @@ var deploy = {
|
|
|
2376
2800
|
const credentials = extractCredentials(serviceConfig);
|
|
2377
2801
|
const provAuthResult = await provisioner.authenticate(credentials);
|
|
2378
2802
|
if (!provAuthResult.success) {
|
|
2379
|
-
|
|
2803
|
+
logger12.error("Provisioner authentication failed", {
|
|
2380
2804
|
service: serviceType,
|
|
2381
2805
|
provisioner: serviceConfig.provisioner
|
|
2382
2806
|
});
|
|
2383
2807
|
currentProvisioners.clear();
|
|
2384
2808
|
return err(provAuthResult.error);
|
|
2385
2809
|
}
|
|
2386
|
-
|
|
2810
|
+
logger12.info("Provisioner authenticated", {
|
|
2387
2811
|
service: serviceType,
|
|
2388
2812
|
user: provAuthResult.data
|
|
2389
2813
|
});
|
|
@@ -2393,7 +2817,7 @@ var deploy = {
|
|
|
2393
2817
|
currentProvider = provider;
|
|
2394
2818
|
currentContainerProvider = containerProvider;
|
|
2395
2819
|
currentConfig = parsed;
|
|
2396
|
-
|
|
2820
|
+
logger12.info("Deploy module initialized", {
|
|
2397
2821
|
provider: parsed.provider.type,
|
|
2398
2822
|
provisioners: [...currentProvisioners.keys()]
|
|
2399
2823
|
});
|
|
@@ -2403,7 +2827,7 @@ var deploy = {
|
|
|
2403
2827
|
currentContainerProvider = null;
|
|
2404
2828
|
currentConfig = null;
|
|
2405
2829
|
currentProvisioners.clear();
|
|
2406
|
-
|
|
2830
|
+
logger12.error("Deploy module initialization failed", { error });
|
|
2407
2831
|
return err(
|
|
2408
2832
|
HaiDeployError.CONFIG_ERROR,
|
|
2409
2833
|
deployM("deploy_configError", {
|
|
@@ -2418,15 +2842,15 @@ var deploy = {
|
|
|
2418
2842
|
},
|
|
2419
2843
|
async close() {
|
|
2420
2844
|
if (!currentProvider && !currentContainerProvider) {
|
|
2421
|
-
|
|
2845
|
+
logger12.info("Deploy module already closed, skipping");
|
|
2422
2846
|
return;
|
|
2423
2847
|
}
|
|
2424
|
-
|
|
2848
|
+
logger12.info("Closing deploy module");
|
|
2425
2849
|
currentProvider = null;
|
|
2426
2850
|
currentContainerProvider = null;
|
|
2427
2851
|
currentConfig = null;
|
|
2428
2852
|
currentProvisioners.clear();
|
|
2429
|
-
|
|
2853
|
+
logger12.info("Deploy module closed");
|
|
2430
2854
|
},
|
|
2431
2855
|
get config() {
|
|
2432
2856
|
return currentConfig;
|
|
@@ -2435,22 +2859,22 @@ var deploy = {
|
|
|
2435
2859
|
return currentProvider !== null || currentContainerProvider !== null;
|
|
2436
2860
|
},
|
|
2437
2861
|
async scan(appDir, options) {
|
|
2438
|
-
|
|
2862
|
+
logger12.debug("Scanning application", { appDir });
|
|
2439
2863
|
return scanApp(appDir, options);
|
|
2440
2864
|
},
|
|
2441
2865
|
async provisionAll(projectName, services) {
|
|
2442
2866
|
if (!currentProvider && !currentContainerProvider) {
|
|
2443
2867
|
return notInitialized.result();
|
|
2444
2868
|
}
|
|
2445
|
-
|
|
2869
|
+
logger12.info("Provisioning all services", { projectName, count: currentProvisioners.size });
|
|
2446
2870
|
const results = [];
|
|
2447
2871
|
for (const [serviceType, provisioner] of currentProvisioners) {
|
|
2448
2872
|
if (services && !services.includes(serviceType))
|
|
2449
2873
|
continue;
|
|
2450
|
-
|
|
2874
|
+
logger12.debug("Provisioning service", { serviceType, provisioner: provisioner.name });
|
|
2451
2875
|
const provResult = await provisioner.provision(projectName);
|
|
2452
2876
|
if (!provResult.success) {
|
|
2453
|
-
|
|
2877
|
+
logger12.error("Service provisioning failed", {
|
|
2454
2878
|
serviceType,
|
|
2455
2879
|
provisioner: provisioner.name,
|
|
2456
2880
|
code: provResult.error.code
|
|
@@ -2467,7 +2891,7 @@ var deploy = {
|
|
|
2467
2891
|
});
|
|
2468
2892
|
}
|
|
2469
2893
|
results.push(provResult.data);
|
|
2470
|
-
|
|
2894
|
+
logger12.info("Service provisioned", { serviceType, provisioner: provisioner.name });
|
|
2471
2895
|
}
|
|
2472
2896
|
return ok(results);
|
|
2473
2897
|
},
|
|
@@ -2475,7 +2899,7 @@ var deploy = {
|
|
|
2475
2899
|
if (!currentProvider && !currentContainerProvider) {
|
|
2476
2900
|
return notInitialized.result();
|
|
2477
2901
|
}
|
|
2478
|
-
|
|
2902
|
+
logger12.info("Starting deployment", { appDir, environment: options?.environment, projectName: options?.projectName });
|
|
2479
2903
|
const scanOptions = { ...options, environment: options?.environment ?? currentConfig?.environment };
|
|
2480
2904
|
const scanResult = await scanApp(appDir, scanOptions);
|
|
2481
2905
|
if (!scanResult.success) {
|
|
@@ -2491,11 +2915,21 @@ var deploy = {
|
|
|
2491
2915
|
options = {
|
|
2492
2916
|
...options,
|
|
2493
2917
|
...scanOptions.environment ? { environment: environment.data.environment } : {},
|
|
2494
|
-
...options?.
|
|
2918
|
+
...options?.skipMiddleware ? { serviceModes: Object.fromEntries(scan.requiredServices.map((service) => [service, "keep"])) } : {}
|
|
2495
2919
|
};
|
|
2920
|
+
if (options.selectedServices) {
|
|
2921
|
+
options.serviceModes = {
|
|
2922
|
+
...options.serviceModes,
|
|
2923
|
+
...Object.fromEntries(scan.requiredServices.filter((service) => !options.selectedServices.includes(service)).map((service) => [service, "keep"]))
|
|
2924
|
+
};
|
|
2925
|
+
}
|
|
2496
2926
|
if (currentContainerProvider) {
|
|
2497
2927
|
return deployContainerApp(currentContainerProvider, appDir, scan, projectName, options, appEnv);
|
|
2498
2928
|
}
|
|
2929
|
+
if (options?.bundleOnly)
|
|
2930
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_bundleOnlyContainer"));
|
|
2931
|
+
if (options.selectedServices)
|
|
2932
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_selectedContainerOnly"));
|
|
2499
2933
|
if (!currentProvider) {
|
|
2500
2934
|
return notInitialized.result();
|
|
2501
2935
|
}
|
|
@@ -2507,9 +2941,11 @@ var deploy = {
|
|
|
2507
2941
|
})
|
|
2508
2942
|
);
|
|
2509
2943
|
}
|
|
2944
|
+
if (!existsSync(resolveOutputDir(appDir, "vercel")))
|
|
2945
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_artifactInvalid"));
|
|
2510
2946
|
let allEnvVars = appEnv;
|
|
2511
2947
|
const recovery = { projectName, stage: "provision", resources: [] };
|
|
2512
|
-
if (!options?.
|
|
2948
|
+
if (!options?.skipMiddleware) {
|
|
2513
2949
|
const provResults = await deploy.provisionAll(projectName, options?.serviceModes ? scan.requiredServices.filter((service) => usesDefaultMiddleware(scan, service, options)) : void 0);
|
|
2514
2950
|
if (!provResults.success) {
|
|
2515
2951
|
return err(provResults.error);
|
|
@@ -2534,12 +2970,6 @@ var deploy = {
|
|
|
2534
2970
|
return recoveryFailure(envResult.error, { ...recovery, stage: "set-env" });
|
|
2535
2971
|
}
|
|
2536
2972
|
}
|
|
2537
|
-
if (!options?.skipBuild) {
|
|
2538
|
-
const buildResult = buildApp(appDir, scan.buildCommand, allEnvVars);
|
|
2539
|
-
if (!buildResult.success) {
|
|
2540
|
-
return recoveryFailure(buildResult.error, { ...recovery, stage: "build" });
|
|
2541
|
-
}
|
|
2542
|
-
}
|
|
2543
2973
|
const outputDir = resolveOutputDir(appDir, currentConfig?.provider.type ?? "vercel");
|
|
2544
2974
|
const deployResult = await currentProvider.deploy(projectId, outputDir);
|
|
2545
2975
|
if (!deployResult.success) {
|
|
@@ -2549,7 +2979,7 @@ var deploy = {
|
|
|
2549
2979
|
...deployResult.data,
|
|
2550
2980
|
envVarsSet: Object.keys(allEnvVars)
|
|
2551
2981
|
};
|
|
2552
|
-
|
|
2982
|
+
logger12.info("Deployment completed", {
|
|
2553
2983
|
url: result.url,
|
|
2554
2984
|
deploymentId: result.deploymentId,
|
|
2555
2985
|
status: result.status
|
|
@@ -2559,21 +2989,41 @@ var deploy = {
|
|
|
2559
2989
|
};
|
|
2560
2990
|
async function deployContainerApp(provider, appDir, scan, rawProjectName, options, appEnv = {}) {
|
|
2561
2991
|
const projectName = normalizeProjectName(rawProjectName);
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2992
|
+
let artifact;
|
|
2993
|
+
let artifactDir = options?.artifactDir ?? resolveArtifactDirectory(appDir);
|
|
2994
|
+
try {
|
|
2995
|
+
if (existsSync(join(artifactDir, "latest"))) {
|
|
2996
|
+
const latest = readFileSync(join(artifactDir, "latest"), "utf8").trim();
|
|
2997
|
+
if (!/^[a-z0-9][\w.+-]*$/i.test(latest))
|
|
2998
|
+
throw new Error("Invalid artifact pointer");
|
|
2999
|
+
artifactDir = join(artifactDir, latest);
|
|
3000
|
+
}
|
|
3001
|
+
artifact = await readDeploymentArtifact(artifactDir);
|
|
3002
|
+
} catch {
|
|
3003
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_artifactInvalid"));
|
|
3004
|
+
}
|
|
3005
|
+
if (artifact.partial && !options?.selectedServices) {
|
|
3006
|
+
const selectedServices = artifact.images.map((image) => image.name);
|
|
3007
|
+
options = {
|
|
3008
|
+
...options,
|
|
3009
|
+
selectedServices,
|
|
3010
|
+
serviceModes: {
|
|
3011
|
+
...options?.serviceModes,
|
|
3012
|
+
...Object.fromEntries(scan.requiredServices.map((service) => [service, "keep"]))
|
|
3013
|
+
}
|
|
3014
|
+
};
|
|
2567
3015
|
}
|
|
3016
|
+
if (!options?.bundleOnly && currentConfig?.provider.type === "docker-ssh" && !currentConfig.provider.ssh)
|
|
3017
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_sshRequired"));
|
|
2568
3018
|
let provisionEnv = {};
|
|
2569
|
-
if (!options?.
|
|
3019
|
+
if (!options?.skipMiddleware) {
|
|
2570
3020
|
const provResults = await deploy.provisionAll(projectName, options?.serviceModes ? scan.requiredServices.filter((service) => usesDefaultMiddleware(scan, service, options)) : void 0);
|
|
2571
3021
|
if (!provResults.success) {
|
|
2572
3022
|
return err(provResults.error);
|
|
2573
3023
|
}
|
|
2574
3024
|
provisionEnv = mergeProvisionEnv(provResults.data, scan, options);
|
|
2575
3025
|
}
|
|
2576
|
-
return provider.deploy({ appDir, projectName, scan, provisionEnv, appEnv, options });
|
|
3026
|
+
return provider.deploy({ appDir, artifact, artifactDir, projectName, scan, provisionEnv, appEnv, options });
|
|
2577
3027
|
}
|
|
2578
3028
|
|
|
2579
3029
|
// src/deploy-middleware.ts
|
|
@@ -2621,6 +3071,6 @@ function describeMiddlewareBackend(backend) {
|
|
|
2621
3071
|
return deployM(backend.purpose);
|
|
2622
3072
|
}
|
|
2623
3073
|
|
|
2624
|
-
export { DEPLOY_MIDDLEWARE, DeployConfigSchema, HaiDeployError, ProviderConfigSchema, deploy, describeMiddlewareBackend, getDeployRecovery, loadDeploymentConfig, loadDeploymentEnvironment, saveDeploymentEnvironment };
|
|
3074
|
+
export { DEPLOY_MIDDLEWARE, DeployConfigSchema, DeploymentArtifactSchema, HaiDeployError, ProviderConfigSchema, artifactChecksum, deploy, describeMiddlewareBackend, getDeployRecovery, loadDeploymentConfig, loadDeploymentEnvironment, readDeploymentArtifact, saveDeploymentEnvironment };
|
|
2625
3075
|
//# sourceMappingURL=index.js.map
|
|
2626
3076
|
//# sourceMappingURL=index.js.map
|