@h-ai/deploy 0.1.0-alpha.66 → 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 +97 -37
- package/README.ja.md +97 -37
- package/README.md +101 -78
- package/dist/index.d.ts +13 -3
- package/dist/index.js +547 -113
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'crypto';
|
|
2
|
-
import { createReadStream, readFileSync, existsSync, lstatSync, writeFileSync, chmodSync, mkdirSync, mkdtempSync, copyFileSync, rmSync, statSync, readdirSync } from 'fs';
|
|
2
|
+
import { createReadStream, readFileSync, existsSync, lstatSync, writeFileSync, chmodSync, mkdirSync, mkdtempSync, copyFileSync, renameSync, rmSync, statSync, readdirSync } from 'fs';
|
|
3
3
|
import { resolve, join, basename, dirname, relative, sep } from 'path';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
-
import { core,
|
|
5
|
+
import { core, err, ok } from '@h-ai/core';
|
|
6
6
|
import process2 from 'process';
|
|
7
7
|
import { parseEnv } from 'util';
|
|
8
|
-
import { parse, stringify, parseAllDocuments } from 'yaml';
|
|
8
|
+
import { LineCounter, parseDocument, parse, stringify, parseAllDocuments } from 'yaml';
|
|
9
9
|
import { homedir } from 'os';
|
|
10
10
|
import { spawn } from 'child_process';
|
|
11
11
|
import { Buffer } from 'buffer';
|
|
@@ -13,25 +13,33 @@ import { Buffer } from 'buffer';
|
|
|
13
13
|
// src/deploy-artifact.ts
|
|
14
14
|
var DeploymentArtifactSchema = z.object({
|
|
15
15
|
/** 清单格式版本,与应用版本和部署环境无关。 */
|
|
16
|
-
version: z.literal(
|
|
16
|
+
version: z.literal(3),
|
|
17
17
|
/** 应用根 package.json 的版本,用于归档、Compose 与桌面交付;最长 80 字符。 */
|
|
18
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
|
-
/**
|
|
19
|
+
/** 本次构建标识;同一应用版本允许重复构建。 */
|
|
20
20
|
buildId: z.string().regex(/^[a-z0-9][\w.+-]*$/i),
|
|
21
|
-
/**
|
|
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
|
+
/** 本次构建选择的镜像归档。 */
|
|
22
30
|
images: z.array(z.object({
|
|
23
31
|
/** app 为服务或独立应用,web 为同源 API 代理的静态前端。 */
|
|
24
32
|
name: z.enum(["app", "web"]),
|
|
25
33
|
/** 相对于清单目录的 tar 文件名。 */
|
|
26
34
|
file: z.string().regex(/^[\w.+-]+\.tar$/),
|
|
27
35
|
/** 归档导入后可直接使用的不可变镜像引用。 */
|
|
28
|
-
imageRef: z.string().regex(/^[a-z0-9][a-z0-9./:_-]
|
|
36
|
+
imageRef: z.string().regex(/^[a-z0-9][a-z0-9./:_-]*:[a-z0-9_][a-z0-9_.-]*$/).refine((value) => !value.endsWith(":latest")),
|
|
29
37
|
/** tar 内容的 SHA-256,用于部署前校验。 */
|
|
30
38
|
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
31
39
|
/** 容器内部监听端口;服务器公开端口由部署选择。 */
|
|
32
40
|
containerPort: z.number().int().min(1).max(65535).optional()
|
|
33
|
-
})).min(1).max(2).refine((images) =>
|
|
34
|
-
}).refine((artifact) => artifact.images.every((image) => image.file === `${image.name}-${artifact.appVersion}.tar`));
|
|
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"));
|
|
35
43
|
async function artifactChecksum(file) {
|
|
36
44
|
const hash = createHash("sha256");
|
|
37
45
|
for await (const chunk of createReadStream(file))
|
|
@@ -99,11 +107,21 @@ var en_US_default = {
|
|
|
99
107
|
deploy_purposeEmail: "Email/HTTP delivery; the default SMTP container only captures test messages",
|
|
100
108
|
deploy_purposeSms: "SMS through Aliyun credentials; requires an existing signature and quota",
|
|
101
109
|
deploy_webProxyInvalid: "The API prefix must be an absolute path containing only letters, numbers, underscores, hyphens and slashes.",
|
|
102
|
-
deploy_artifactInvalid: "
|
|
110
|
+
deploy_artifactInvalid: "Image artifacts are missing or invalid. Run pnpm build --target docker, select valid --artifact-dir, or use pnpm run deploy --build.",
|
|
103
111
|
deploy_sshRequired: "Automatic deployment requires SSH settings. Use --bundle-only for manual transfer when the server is unreachable.",
|
|
104
|
-
deploy_bundleInstructions: "
|
|
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}",
|
|
105
113
|
deploy_bundleMetadata: "Application version: {version}\nBuild ID: {buildId}\nCompose file: {compose}",
|
|
106
|
-
deploy_bundleOnlyContainer: "Compose bundle generation is supported only by docker-ssh."
|
|
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."
|
|
107
125
|
};
|
|
108
126
|
|
|
109
127
|
// messages/zh-CN.json
|
|
@@ -157,11 +175,21 @@ var zh_CN_default = {
|
|
|
157
175
|
deploy_purposeEmail: "\u90AE\u4EF6\u6216 HTTP \u901A\u77E5\u6295\u9012\uFF1B\u9ED8\u8BA4 SMTP \u5BB9\u5668\u4EC5\u7528\u4E8E\u6D4B\u8BD5\u63A5\u6536",
|
|
158
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",
|
|
159
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",
|
|
160
|
-
deploy_artifactInvalid: "\
|
|
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",
|
|
161
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",
|
|
162
|
-
deploy_bundleInstructions: "\
|
|
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}",
|
|
163
181
|
deploy_bundleMetadata: "\u5E94\u7528\u7248\u672C\uFF1A{version}\n\u6784\u5EFA\u7F16\u53F7\uFF1A{buildId}\nCompose \u6587\u4EF6\uFF1A{compose}",
|
|
164
|
-
deploy_bundleOnlyContainer: "\u4EC5\u751F\u6210 Compose tar \u53EA\u652F\u6301 docker-ssh\u3002"
|
|
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"
|
|
165
193
|
};
|
|
166
194
|
|
|
167
195
|
// src/deploy-i18n.ts
|
|
@@ -177,11 +205,11 @@ var VercelProviderConfigSchema = z.object({
|
|
|
177
205
|
});
|
|
178
206
|
var SshConfigSchema = z.object({
|
|
179
207
|
/** 远程主机地址或 ~/.ssh/config 中的 Host 别名 */
|
|
180
|
-
host: z.string().
|
|
208
|
+
host: z.string().regex(/^[a-z0-9][a-z0-9.:-]*$/i),
|
|
181
209
|
/** SSH 端口 */
|
|
182
210
|
port: z.number().int().min(1).max(65535).default(22),
|
|
183
211
|
/** SSH 用户名 */
|
|
184
|
-
username: z.string().
|
|
212
|
+
username: z.string().regex(/^[a-z_][\w-]*$/i),
|
|
185
213
|
/** 私钥文件路径(省略时使用 ssh-agent 或 ~/.ssh 默认密钥) */
|
|
186
214
|
identityFile: z.string().min(1).optional(),
|
|
187
215
|
/** known_hosts 文件路径(省略时使用默认;配合 CI/临时环境隔离主机指纹) */
|
|
@@ -206,7 +234,7 @@ var DockerSshProviderConfigSchema = z.object({
|
|
|
206
234
|
/** 远程主机运行参数 */
|
|
207
235
|
remote: z.object({
|
|
208
236
|
/** 远程应用根目录 */
|
|
209
|
-
baseDir: z.string().
|
|
237
|
+
baseDir: z.string().regex(/^\/[\w/. -]+$/).refine((value) => !value.split("/").includes("..")).default("/opt/hai/apps"),
|
|
210
238
|
/** 远程容器运行时(auto 优先 podman) */
|
|
211
239
|
runtime: z.enum(["auto", "docker", "podman"]).default("auto")
|
|
212
240
|
}).default({ baseDir: "/opt/hai/apps", runtime: "auto" }),
|
|
@@ -305,10 +333,16 @@ function resolveConfig(value, prefix, env) {
|
|
|
305
333
|
}
|
|
306
334
|
function loadDeploymentConfig(name, path, values) {
|
|
307
335
|
try {
|
|
308
|
-
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" } }));
|
|
309
343
|
return ok(config && typeof config === "object" && !Array.isArray(config) ? config : {});
|
|
310
344
|
} catch {
|
|
311
|
-
return err(HaiDeployError.CONFIG_ERROR, deployM("
|
|
345
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_configFileUnreadable", { params: { path } }));
|
|
312
346
|
}
|
|
313
347
|
}
|
|
314
348
|
function loadDeploymentEnvironment(options) {
|
|
@@ -759,6 +793,7 @@ function maskSecrets(text, secrets) {
|
|
|
759
793
|
function runCommand(command, args, options = {}) {
|
|
760
794
|
return new Promise((resolve6) => {
|
|
761
795
|
const child = spawn(command, args, {
|
|
796
|
+
windowsHide: true,
|
|
762
797
|
cwd: options.cwd,
|
|
763
798
|
env: options.env ? { ...process2.env, ...options.env } : process2.env,
|
|
764
799
|
shell: false
|
|
@@ -787,6 +822,7 @@ function runCommand(command, args, options = {}) {
|
|
|
787
822
|
}
|
|
788
823
|
child.stdout.on("data", (chunk) => {
|
|
789
824
|
stdout += chunk.toString("utf-8");
|
|
825
|
+
options.onOutput?.(maskSecrets(chunk.toString("utf-8"), options.secrets));
|
|
790
826
|
});
|
|
791
827
|
child.stderr.on("data", (chunk) => {
|
|
792
828
|
stderr += chunk.toString("utf-8");
|
|
@@ -830,62 +866,393 @@ ${stderr || `exit ${exitCode}`}`, options.secrets).slice(-3e3) }
|
|
|
830
866
|
});
|
|
831
867
|
}
|
|
832
868
|
|
|
833
|
-
// src/container/deploy-
|
|
869
|
+
// src/container/deploy-scripts.ts
|
|
834
870
|
function shellArgument(value) {
|
|
835
871
|
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
836
872
|
}
|
|
837
|
-
|
|
838
|
-
const
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
for (const image of artifact.images)
|
|
851
|
-
copyFileSync(join(artifactDir, image.file), join(staging, "images", image.file));
|
|
852
|
-
for (const file of [composeName, ".env.runtime", ...artifact.images.some((image) => image.name === "web") ? ["web.conf"] : []])
|
|
853
|
-
copyFileSync(join(directory, file), join(staging, file));
|
|
854
|
-
const compose = parse(readFileSync(join(directory, composeName), "utf8"));
|
|
855
|
-
const images = [...new Set(Object.values(compose.services).flatMap((service) => service.image ? [service.image] : []))];
|
|
856
|
-
const start = `#!/bin/sh
|
|
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
|
|
857
886
|
set -eu
|
|
858
|
-
cd "$(dirname "$0")"
|
|
859
887
|
umask 077
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
|
864
997
|
if [ "$runtime" = auto ]; then
|
|
865
998
|
if command -v podman >/dev/null 2>&1 && podman version >/dev/null 2>&1; then runtime=podman; else runtime=docker; fi
|
|
866
999
|
fi
|
|
867
|
-
case "$runtime" in docker|podman) ;; *)
|
|
1000
|
+
case "$runtime" in docker|podman) ;; *) printf 'Runtime must be docker or podman\n' >&2; exit 1;; esac
|
|
868
1001
|
"$runtime" version >/dev/null
|
|
869
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"
|
|
870
1007
|
sha256sum -c SHA256SUMS
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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; }
|
|
876
1089
|
done
|
|
877
|
-
|
|
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 }
|
|
878
1157
|
`;
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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" } })}
|
|
884
1252
|
${instructions}
|
|
885
1253
|
`);
|
|
886
|
-
copyFileSync(join(artifactDir, "manifest.json"), join(staging, "manifest.json"));
|
|
887
1254
|
const checksums = [];
|
|
888
|
-
for (const file of
|
|
1255
|
+
for (const file of files)
|
|
889
1256
|
checksums.push(`${await artifactChecksum(join(staging, file))} ${file}`);
|
|
890
1257
|
writeFileSync(join(staging, "SHA256SUMS"), `${checksums.join("\n")}
|
|
891
1258
|
`);
|
|
@@ -893,25 +1260,33 @@ ${instructions}
|
|
|
893
1260
|
if (!tar.success)
|
|
894
1261
|
throw new Error(tar.error.message);
|
|
895
1262
|
chmodSync(temporaryTar, 384);
|
|
896
|
-
const { renameSync } = await import('fs');
|
|
897
1263
|
renameSync(temporaryTar, output);
|
|
898
|
-
|
|
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" };
|
|
899
1274
|
} finally {
|
|
900
1275
|
rmSync(staging, { recursive: true, force: true });
|
|
901
1276
|
rmSync(temporaryTar, { force: true });
|
|
902
1277
|
}
|
|
903
1278
|
}
|
|
904
1279
|
var IMAGES = {
|
|
905
|
-
postgres: "postgres:16-alpine",
|
|
906
|
-
mysql: "mysql:8.4",
|
|
907
|
-
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",
|
|
908
1283
|
rustfs: "ghcr.io/rustfs/rustfs:1.0.1",
|
|
909
1284
|
minio: "ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z",
|
|
910
|
-
qdrant: "qdrant/qdrant:v1.
|
|
911
|
-
pgvector: "pgvector/pgvector:pg17",
|
|
912
|
-
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",
|
|
913
1288
|
ldap: "docker.io/osixia/openldap:1.5.0",
|
|
914
|
-
smtp: "axllent/mailpit:v1.27.4"
|
|
1289
|
+
smtp: "docker.io/axllent/mailpit:v1.27.4"
|
|
915
1290
|
};
|
|
916
1291
|
function generatePassword() {
|
|
917
1292
|
return randomBytes(24).toString("base64url");
|
|
@@ -920,8 +1295,12 @@ function resolveSidecarSecrets(appDir, projectName) {
|
|
|
920
1295
|
const secretFile = join(appDir, ".hai", "deploy", "runtime", `${projectName}.secrets.json`);
|
|
921
1296
|
if (existsSync(secretFile)) {
|
|
922
1297
|
try {
|
|
923
|
-
|
|
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;
|
|
924
1302
|
} catch {
|
|
1303
|
+
throw new Error(deployM("deploy_secretsInvalid", { params: { path: secretFile } }));
|
|
925
1304
|
}
|
|
926
1305
|
}
|
|
927
1306
|
const secrets = {
|
|
@@ -1089,6 +1468,28 @@ function planChromaVecdb(plan, projectName, net) {
|
|
|
1089
1468
|
plan.env.HAI_VECDB = JSON.stringify({ type: "chroma", url: "http://vecdb:8000" });
|
|
1090
1469
|
}
|
|
1091
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 };
|
|
1092
1493
|
const plan = {
|
|
1093
1494
|
services: [],
|
|
1094
1495
|
composeServices: {},
|
|
@@ -1215,23 +1616,6 @@ function generateComposeFile(options) {
|
|
|
1215
1616
|
};
|
|
1216
1617
|
if (Object.keys(sidecar.volumes).length > 0)
|
|
1217
1618
|
compose.volumes = sidecar.volumes;
|
|
1218
|
-
if (options.previousCompose && options.serviceModes) {
|
|
1219
|
-
const previous = parse(options.previousCompose);
|
|
1220
|
-
for (const [service, mode] of Object.entries(options.serviceModes)) {
|
|
1221
|
-
if (mode !== "keep")
|
|
1222
|
-
continue;
|
|
1223
|
-
for (const name of [service, `${service}-init`]) {
|
|
1224
|
-
if (previous.services?.[name]) {
|
|
1225
|
-
services[name] = previous.services[name];
|
|
1226
|
-
app.depends_on = [.../* @__PURE__ */ new Set([...app.depends_on ?? [], service])];
|
|
1227
|
-
}
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
|
-
const previousMounts = previous.services?.app?.volumes;
|
|
1231
|
-
if (Array.isArray(previousMounts) && Object.values(options.serviceModes).includes("keep"))
|
|
1232
|
-
app.volumes = [.../* @__PURE__ */ new Set([...app.volumes ?? [], ...previousMounts])];
|
|
1233
|
-
compose.volumes = { ...previous.volumes, ...sidecar.volumes };
|
|
1234
|
-
}
|
|
1235
1619
|
if (services["storage-init"]) {
|
|
1236
1620
|
app.depends_on = {
|
|
1237
1621
|
...Object.fromEntries((app.depends_on ?? []).map((name) => [name, { condition: "service_started" }])),
|
|
@@ -1318,14 +1702,15 @@ function buildScpUploadArgs(config, localPath, remotePath) {
|
|
|
1318
1702
|
"-P",
|
|
1319
1703
|
String(config.port),
|
|
1320
1704
|
localPath,
|
|
1321
|
-
`${config.username}@${config.host}:${remotePath}`
|
|
1705
|
+
`${config.username}@${config.host.includes(":") ? `[${config.host}]` : config.host}:${remotePath}`
|
|
1322
1706
|
];
|
|
1323
1707
|
}
|
|
1324
1708
|
function createSshExecutor(config) {
|
|
1325
1709
|
const exec = async (remoteCommand, options) => {
|
|
1326
1710
|
return runCommand("ssh", buildSshExecArgs(config, remoteCommand), {
|
|
1327
1711
|
timeoutMs: options?.timeoutMs,
|
|
1328
|
-
secrets: options?.secrets
|
|
1712
|
+
secrets: options?.secrets,
|
|
1713
|
+
onOutput: options?.onOutput
|
|
1329
1714
|
});
|
|
1330
1715
|
};
|
|
1331
1716
|
const upload = async (localPath, remotePath) => {
|
|
@@ -1414,11 +1799,23 @@ function createDockerSshProvider(config, container) {
|
|
|
1414
1799
|
const images = artifact.images;
|
|
1415
1800
|
const web = images.find((image) => image.name === "web");
|
|
1416
1801
|
const app = images.find((image) => image.name === "app");
|
|
1417
|
-
|
|
1418
|
-
|
|
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(", ") } }));
|
|
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}`;
|
|
1419
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"));
|
|
1420
1817
|
let webProxy;
|
|
1421
|
-
if (
|
|
1818
|
+
if (hasWeb) {
|
|
1422
1819
|
const server = loadDeploymentConfig("serv", join(appDir, "config", "_serv.yml"), env);
|
|
1423
1820
|
if (!server.success)
|
|
1424
1821
|
return server;
|
|
@@ -1427,12 +1824,10 @@ function createDockerSshProvider(config, container) {
|
|
|
1427
1824
|
const apiPrefix = http?.apiPrefix ?? "/api/v1";
|
|
1428
1825
|
if (typeof apiPrefix !== "string" || !/^\/[\w/-]+$/.test(apiPrefix))
|
|
1429
1826
|
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_webProxyInvalid"));
|
|
1430
|
-
webProxy = generateWebProxyConfig(containerPort, apiPrefix,
|
|
1827
|
+
webProxy = generateWebProxyConfig(containerPort, apiPrefix, webContainerPort);
|
|
1431
1828
|
env.HAI_SERV = JSON.stringify({ ...server.data, cors: { ...cors, origin: (!cors?.origin || cors.origin === "*") && config.ssh ? url : cors?.origin ?? "*" } });
|
|
1432
1829
|
}
|
|
1433
1830
|
const envPath = join(artifactDir, ".env.runtime");
|
|
1434
|
-
const previousEnv = existsSync(envPath) ? parseEnv(readFileSync(envPath, "utf8")) : {};
|
|
1435
|
-
Object.assign(env, { ...previousEnv, ...env });
|
|
1436
1831
|
writeFileSync(envPath, serializeEnvFile(env), { mode: 384 });
|
|
1437
1832
|
if (options?.environment) {
|
|
1438
1833
|
const environment = loadDeploymentEnvironment({ environment: options.environment, envDir: options.envDir ?? scan.envDir ?? appDir });
|
|
@@ -1443,36 +1838,46 @@ function createDockerSshProvider(config, container) {
|
|
|
1443
1838
|
return saved;
|
|
1444
1839
|
}
|
|
1445
1840
|
const composeName = `docker-compose-${artifact.appVersion}.yml`;
|
|
1446
|
-
const latestPath = join(artifactDir, "latest");
|
|
1447
|
-
const previousName = existsSync(latestPath) ? readFileSync(latestPath, "utf8").trim() : "";
|
|
1448
|
-
const previousPath = /^[\w.+-]+\.yml$/.test(previousName) ? join(artifactDir, previousName) : void 0;
|
|
1449
1841
|
const composeContent = generateComposeFile({
|
|
1450
1842
|
projectName,
|
|
1451
1843
|
appVersion: artifact.appVersion,
|
|
1452
|
-
imageRef: app
|
|
1844
|
+
imageRef: app?.imageRef ?? "localhost/hai-unselected:0",
|
|
1453
1845
|
hostPort,
|
|
1454
1846
|
containerPort,
|
|
1455
1847
|
sidecar,
|
|
1456
|
-
web:
|
|
1457
|
-
serviceModes: options?.serviceModes,
|
|
1458
|
-
previousCompose: previousPath && existsSync(previousPath) ? readFileSync(previousPath, "utf8") : void 0
|
|
1848
|
+
web: hasWeb ? { imageRef: web?.imageRef ?? "localhost/hai-unselected:0", containerPort: webContainerPort } : void 0
|
|
1459
1849
|
});
|
|
1460
1850
|
const composePath = join(artifactDir, composeName);
|
|
1461
1851
|
writeFileSync(composePath, composeContent);
|
|
1462
1852
|
if (webProxy)
|
|
1463
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");
|
|
1464
1864
|
const bundle = await createDeploymentBundle({
|
|
1465
1865
|
directory: artifactDir,
|
|
1466
1866
|
artifactDir: resolvedBuildDir,
|
|
1467
1867
|
artifact,
|
|
1468
1868
|
projectName,
|
|
1469
1869
|
environment: options?.environment ?? "prod",
|
|
1470
|
-
|
|
1471
|
-
|
|
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"],
|
|
1472
1879
|
output: options?.output ?? join(artifactDir, `bundle-${projectName}-${deploymentId}-${artifact.appVersion}.tar`)
|
|
1473
1880
|
});
|
|
1474
|
-
writeFileSync(latestPath, `${composeName}
|
|
1475
|
-
`);
|
|
1476
1881
|
if (options?.bundleOnly)
|
|
1477
1882
|
return ok({ url: "", deploymentId, status: "packaged", provider: "docker-ssh", healthCheck: "skipped", envVarsSet: Object.keys(env), ...bundle });
|
|
1478
1883
|
if (!ssh)
|
|
@@ -1484,22 +1889,31 @@ function createDockerSshProvider(config, container) {
|
|
|
1484
1889
|
if (!remoteRuntime.success)
|
|
1485
1890
|
return remoteRuntime;
|
|
1486
1891
|
const remoteDir = `${baseDir}/${projectName}`;
|
|
1487
|
-
const remoteTar = `${remoteDir}/${basename(bundle.bundlePath)}`;
|
|
1488
|
-
const prepared = await ssh.exec(`mkdir -p -- ${shellArgument(remoteDir)} && chmod 700 -- ${shellArgument(remoteDir)}`);
|
|
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`)}`);
|
|
1489
1894
|
if (!prepared.success)
|
|
1490
1895
|
return prepared;
|
|
1491
1896
|
const uploaded = await ssh.upload(bundle.bundlePath, remoteTar);
|
|
1492
1897
|
if (!uploaded.success)
|
|
1493
1898
|
return uploaded;
|
|
1899
|
+
let pendingOutput = "";
|
|
1494
1900
|
const started = await ssh.exec(
|
|
1495
|
-
`
|
|
1496
|
-
{ timeoutMs:
|
|
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
|
+
} }
|
|
1497
1912
|
);
|
|
1498
1913
|
if (!started.success)
|
|
1499
1914
|
return started;
|
|
1500
|
-
await ssh.exec(`rm -f -- ${shellArgument(remoteTar)} ${images.map((image) => shellArgument(`${remoteDir}/images/${image.file}`)).join(" ")}`);
|
|
1501
1915
|
logger5.info("Running health check", { url });
|
|
1502
|
-
const healthy = await healthCheck(`${url}${!
|
|
1916
|
+
const healthy = await healthCheck(`${url}${!hasWeb && scan.isSvelteKit ? "/" : "/health"}`) && (!hasWeb || await healthCheck(`${url}/_hai/web-health`) && await healthCheck(`${url}/`));
|
|
1503
1917
|
if (!healthy) {
|
|
1504
1918
|
return err(
|
|
1505
1919
|
HaiDeployError.HEALTH_CHECK_FAILED,
|
|
@@ -1523,7 +1937,8 @@ function createDockerSshProvider(config, container) {
|
|
|
1523
1937
|
return await deploy2(context);
|
|
1524
1938
|
} catch (error) {
|
|
1525
1939
|
logger5.error("Deployment bundle failed", { error });
|
|
1526
|
-
|
|
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);
|
|
1527
1942
|
}
|
|
1528
1943
|
};
|
|
1529
1944
|
return { name: "docker-ssh", deploy: deploySafely };
|
|
@@ -2502,11 +2917,19 @@ var deploy = {
|
|
|
2502
2917
|
...scanOptions.environment ? { environment: environment.data.environment } : {},
|
|
2503
2918
|
...options?.skipMiddleware ? { serviceModes: Object.fromEntries(scan.requiredServices.map((service) => [service, "keep"])) } : {}
|
|
2504
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
|
+
}
|
|
2505
2926
|
if (currentContainerProvider) {
|
|
2506
2927
|
return deployContainerApp(currentContainerProvider, appDir, scan, projectName, options, appEnv);
|
|
2507
2928
|
}
|
|
2508
2929
|
if (options?.bundleOnly)
|
|
2509
2930
|
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_bundleOnlyContainer"));
|
|
2931
|
+
if (options.selectedServices)
|
|
2932
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_selectedContainerOnly"));
|
|
2510
2933
|
if (!currentProvider) {
|
|
2511
2934
|
return notInitialized.result();
|
|
2512
2935
|
}
|
|
@@ -2579,6 +3002,17 @@ async function deployContainerApp(provider, appDir, scan, rawProjectName, option
|
|
|
2579
3002
|
} catch {
|
|
2580
3003
|
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_artifactInvalid"));
|
|
2581
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
|
+
};
|
|
3015
|
+
}
|
|
2582
3016
|
if (!options?.bundleOnly && currentConfig?.provider.type === "docker-ssh" && !currentConfig.provider.ssh)
|
|
2583
3017
|
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_sshRequired"));
|
|
2584
3018
|
let provisionEnv = {};
|