@h-ai/deploy 1.0.0-beta.2 → 1.0.0-beta.4
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 +22 -8
- package/README.ja.md +23 -9
- package/README.md +24 -10
- package/dist/deploy-runtime-env.mjs +58 -0
- package/dist/index.d.ts +71 -4
- package/dist/index.js +540 -91
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
|
-
import { createHash, randomUUID, randomBytes } from 'crypto';
|
|
2
|
-
import { createReadStream, readFileSync, existsSync, lstatSync, writeFileSync, readdirSync, realpathSync, chmodSync, mkdirSync, mkdtempSync, copyFileSync, renameSync, rmSync,
|
|
1
|
+
import { createHash, randomUUID, randomBytes, X509Certificate, createPublicKey, createPrivateKey } from 'crypto';
|
|
2
|
+
import { createReadStream, readFileSync, existsSync, lstatSync, writeFileSync, readdirSync, realpathSync, chmodSync, mkdirSync, statSync, mkdtempSync, copyFileSync, renameSync, rmSync, createWriteStream } from 'fs';
|
|
3
3
|
import { resolve, extname, join, dirname, relative, basename, sep } from 'path';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
-
import { core, err, ok, RequestAccessConfigSchema, TrustedProxiesSchema } from '@h-ai/core';
|
|
5
|
+
import { core, err, ok, CoreConfigSchema, RequestAccessConfigSchema, TrustedProxiesSchema } from '@h-ai/core';
|
|
6
6
|
import process6 from 'process';
|
|
7
7
|
import { parseEnv } from 'util';
|
|
8
8
|
import { LineCounter, parseDocument, parse, stringify, parseAllDocuments } from 'yaml';
|
|
9
9
|
import { homedir, tmpdir } from 'os';
|
|
10
|
+
import { request } from 'https';
|
|
11
|
+
import { rootCertificates } from 'tls';
|
|
10
12
|
import { resolveFeedbackBudget } from '@h-ai/api-contract/feedback';
|
|
11
13
|
import { resolveTransportBodyLimits, createTransportBodyPolicy, parseTransportServerKeyRing } from '@h-ai/crypto';
|
|
14
|
+
import { Transform } from 'stream';
|
|
15
|
+
import { pipeline } from 'stream/promises';
|
|
12
16
|
import { spawn } from 'child_process';
|
|
13
17
|
import { readdir, readFile } from 'fs/promises';
|
|
14
18
|
import { setTimeout as setTimeout$1 } from 'timers/promises';
|
|
15
19
|
import { Buffer } from 'buffer';
|
|
20
|
+
import { isIP } from 'net';
|
|
16
21
|
|
|
17
22
|
// src/deploy-artifact.ts
|
|
18
23
|
var DeploymentArtifactSchema = z.object({
|
|
@@ -119,7 +124,7 @@ var en_US_default = {
|
|
|
119
124
|
deploy_storageProxyInvalid: "Cannot generate the storage proxy. Use a valid DNS bucket name whose path does not overlap the website, API, or health endpoints.",
|
|
120
125
|
deploy_storagePublicEndpointInvalid: "Invalid storage.publicEndpoint. Use a browser-accessible HTTP(S) address with an optional valid proxy path, without a bucket, credentials, query or fragment.",
|
|
121
126
|
deploy_storageBackendRequired: "A static browser app cannot deploy server-side storage credentials. Configure Storage in Serv or SvelteKit and obtain presigned links through its API.",
|
|
122
|
-
deploy_artifactInvalid: "Image artifacts are missing or invalid. Run pnpm build --target
|
|
127
|
+
deploy_artifactInvalid: "Image artifacts are missing or invalid. Run pnpm build --target image, select valid --artifact-dir, or use pnpm run deploy --build.",
|
|
123
128
|
deploy_sshRequired: "Automatic deployment requires SSH settings. Use --bundle-only for manual transfer when the server is unreachable.",
|
|
124
129
|
deploy_sshPasswordInvalid: "SSH passwords must be nonempty, contain no CR/LF/NUL, and be at most 1023 UTF-8 bytes.",
|
|
125
130
|
deploy_sshAskpassFailed: "Could not prepare the managed SSH password helper. Check OpenSSH and Windows PowerShell 5.",
|
|
@@ -169,7 +174,9 @@ var en_US_default = {
|
|
|
169
174
|
deploy_secretImportFailed: "Legacy credential import was rejected during {phase}; check private input permissions, project/volume identity, live credentials and the deployment lock. No services were updated; inspect the protected state file if commit is unconfirmed",
|
|
170
175
|
deploy_projectNameRequired: "The root package.json.name is missing or cannot be normalized into an image and deployment resource identity.",
|
|
171
176
|
deploy_projectNameOverrideRemoved: "Deployment identity comes from the root package.json.name; remove the obsolete projectName override.",
|
|
172
|
-
deploy_requestAccessInvalid: "Invalid request access policy ({field}). Check fields, IP addresses, timezones and time windows. For a file reference, use readable YAML no larger than 1 MiB. Fix the configuration and create a new bundle."
|
|
177
|
+
deploy_requestAccessInvalid: "Invalid request access policy ({field}). Check fields, IP addresses, timezones and time windows. For a file reference, use readable YAML no larger than 1 MiB. Fix the configuration and create a new bundle.",
|
|
178
|
+
deploy_httpsPortsInvalid: "HTTP and HTTPS ports must differ; Let\u2019s Encrypt HTTP-01 requires host port 80.",
|
|
179
|
+
deploy_httpsCertificateInvalid: "Invalid HTTPS certificate. Check readable PEM files, matching unencrypted private key, validity dates and domain, then repackage."
|
|
173
180
|
};
|
|
174
181
|
|
|
175
182
|
// messages/ja-JP.json
|
|
@@ -227,7 +234,7 @@ var ja_JP_default = {
|
|
|
227
234
|
deploy_storageProxyInvalid: "\u30B9\u30C8\u30EC\u30FC\u30B8\u30D7\u30ED\u30AD\u30B7\u3092\u751F\u6210\u3067\u304D\u307E\u305B\u3093\u3002\u6709\u52B9\u306A DNS \u30D0\u30B1\u30C3\u30C8\u540D\u3092\u6307\u5B9A\u3057\u3001\u30B5\u30A4\u30C8\u3001API\u3001\u30D8\u30EB\u30B9\u30C1\u30A7\u30C3\u30AF\u306E\u30D1\u30B9\u3068\u306E\u91CD\u8907\u3092\u907F\u3051\u3066\u304F\u3060\u3055\u3044\u3002",
|
|
228
235
|
deploy_storagePublicEndpointInvalid: "storage.publicEndpoint \u304C\u7121\u52B9\u3067\u3059\u3002\u30D6\u30E9\u30A6\u30B6\u30FC\u304B\u3089\u5230\u9054\u53EF\u80FD\u306A HTTP(S) \u30A2\u30C9\u30EC\u30B9\u306B\u6709\u52B9\u306A\u30D7\u30ED\u30AD\u30B7\u30D1\u30B9\u3092\u6307\u5B9A\u3067\u304D\u307E\u3059\u3002\u30D0\u30B1\u30C3\u30C8\u3001\u8A8D\u8A3C\u60C5\u5831\u3001\u30AF\u30A8\u30EA\u3001\u30D5\u30E9\u30B0\u30E1\u30F3\u30C8\u306F\u542B\u3081\u306A\u3044\u3067\u304F\u3060\u3055\u3044\u3002",
|
|
229
236
|
deploy_storageBackendRequired: "\u9759\u7684\u30D6\u30E9\u30A6\u30B6\u30A2\u30D7\u30EA\u306B\u306F\u30B5\u30FC\u30D0\u30FC\u7528\u30B9\u30C8\u30EC\u30FC\u30B8\u8A8D\u8A3C\u60C5\u5831\u3092\u914D\u7F6E\u3067\u304D\u307E\u305B\u3093\u3002Serv \u307E\u305F\u306F SvelteKit \u3067 Storage \u3092\u8A2D\u5B9A\u3057\u3001API \u7D4C\u7531\u3067\u7F72\u540D\u4ED8\u304D\u30EA\u30F3\u30AF\u3092\u53D6\u5F97\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
|
|
230
|
-
deploy_artifactInvalid: "\u30A4\u30E1\u30FC\u30B8\u6210\u679C\u7269\u304C\u5B58\u5728\u3057\u306A\u3044\u304B\u3001\u7121\u52B9\u3067\u3059\u3002pnpm build --target
|
|
237
|
+
deploy_artifactInvalid: "\u30A4\u30E1\u30FC\u30B8\u6210\u679C\u7269\u304C\u5B58\u5728\u3057\u306A\u3044\u304B\u3001\u7121\u52B9\u3067\u3059\u3002pnpm build --target image \u3092\u5B9F\u884C\u3059\u308B\u304B\u3001--artifact-dir \u3067\u6709\u52B9\u306A\u6210\u679C\u7269\u3092\u9078\u629E\u3059\u308B\u304B\u3001pnpm run deploy --build \u3092\u4F7F\u7528\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
|
|
231
238
|
deploy_sshRequired: "\u81EA\u52D5\u30C7\u30D7\u30ED\u30A4\u306B\u306FSSH\u8A2D\u5B9A\u304C\u5FC5\u8981\u3067\u3059\u3002\u30B5\u30FC\u30D0\u30FC\u306B\u63A5\u7D9A\u3067\u304D\u306A\u3044\u5834\u5408\u306F--bundle-only\u3067\u624B\u52D5\u8EE2\u9001\u7528\u306E\u30D0\u30F3\u30C9\u30EB\u3092\u751F\u6210\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
|
|
232
239
|
deploy_sshPasswordInvalid: "SSH\u30D1\u30B9\u30EF\u30FC\u30C9\u306F\u7A7A\u306B\u3067\u304D\u305A\u3001CR/LF/NUL\u3092\u542B\u3081\u3089\u308C\u307E\u305B\u3093\u3002UTF-8\u30671023\u30D0\u30A4\u30C8\u4EE5\u4E0B\u306B\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
|
|
233
240
|
deploy_sshAskpassFailed: "\u7BA1\u7406\u5BFE\u8C61\u306ESSH\u30D1\u30B9\u30EF\u30FC\u30C9helper\u3092\u6E96\u5099\u3067\u304D\u307E\u305B\u3093\u3002OpenSSH\u3068Windows PowerShell 5\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
|
|
@@ -277,7 +284,9 @@ var ja_JP_default = {
|
|
|
277
284
|
deploy_secretImportFailed: "\u65E7\u8A8D\u8A3C\u60C5\u5831\u306E\u30A4\u30F3\u30DD\u30FC\u30C8\u306F {phase} \u6BB5\u968E\u3067\u62D2\u5426\u3055\u308C\u307E\u3057\u305F\u3002\u5165\u529B\u6A29\u9650\u3001\u30D7\u30ED\u30B8\u30A7\u30AF\u30C8\u3068\u30DC\u30EA\u30E5\u30FC\u30E0\u3001\u5B9F\u969B\u306E\u8A8D\u8A3C\u60C5\u5831\u3001\u30C7\u30D7\u30ED\u30A4\u30ED\u30C3\u30AF\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044\u3002\u30B5\u30FC\u30D3\u30B9\u306F\u66F4\u65B0\u3055\u308C\u3066\u3044\u307E\u305B\u3093\u3002\u78BA\u5B9A\u304C\u4E0D\u660E\u306A\u5834\u5408\u306F\u4FDD\u8B77\u3055\u308C\u305F\u72B6\u614B\u30D5\u30A1\u30A4\u30EB\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044",
|
|
278
285
|
deploy_projectNameRequired: "\u5DE5\u7A0B\u30EB\u30FC\u30C8\u306E package.json.name \u304C\u306A\u3044\u304B\u6B63\u898F\u5316\u3067\u304D\u306A\u3044\u305F\u3081\u3001\u30A4\u30E1\u30FC\u30B8\u3068\u30C7\u30D7\u30ED\u30A4\u30EA\u30BD\u30FC\u30B9\u306E\u8B58\u5225\u540D\u3092\u751F\u6210\u3067\u304D\u307E\u305B\u3093\u3002",
|
|
279
286
|
deploy_projectNameOverrideRemoved: "\u30C7\u30D7\u30ED\u30A4\u8B58\u5225\u540D\u306F\u5DE5\u7A0B\u30EB\u30FC\u30C8\u306E package.json.name \u304B\u3089\u53D6\u5F97\u3057\u307E\u3059\u3002\u65E7 projectName \u4E0A\u66F8\u304D\u5F15\u6570\u3092\u524A\u9664\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
|
|
280
|
-
deploy_requestAccessInvalid: "\u30EA\u30AF\u30A8\u30B9\u30C8\u30A2\u30AF\u30BB\u30B9\u8A2D\u5B9A\uFF08{field}\uFF09\u304C\u7121\u52B9\u3067\u3059\u3002\u30D5\u30A3\u30FC\u30EB\u30C9\u3001IP\u3001\u30BF\u30A4\u30E0\u30BE\u30FC\u30F3\u3001\u6642\u9593\u5E2F\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044\u3002\u30D5\u30A1\u30A4\u30EB\u306F\u8AAD\u307F\u53D6\u308A\u53EF\u80FD\u306A 1 MiB \u4EE5\u4E0B\u306E YAML \u3092\u6307\u5B9A\u3057\u3001\u4FEE\u6B63\u5F8C\u306B\u518D\u30D1\u30C3\u30B1\u30FC\u30B8\u3057\u3066\u304F\u3060\u3055\u3044\u3002"
|
|
287
|
+
deploy_requestAccessInvalid: "\u30EA\u30AF\u30A8\u30B9\u30C8\u30A2\u30AF\u30BB\u30B9\u8A2D\u5B9A\uFF08{field}\uFF09\u304C\u7121\u52B9\u3067\u3059\u3002\u30D5\u30A3\u30FC\u30EB\u30C9\u3001IP\u3001\u30BF\u30A4\u30E0\u30BE\u30FC\u30F3\u3001\u6642\u9593\u5E2F\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044\u3002\u30D5\u30A1\u30A4\u30EB\u306F\u8AAD\u307F\u53D6\u308A\u53EF\u80FD\u306A 1 MiB \u4EE5\u4E0B\u306E YAML \u3092\u6307\u5B9A\u3057\u3001\u4FEE\u6B63\u5F8C\u306B\u518D\u30D1\u30C3\u30B1\u30FC\u30B8\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
|
|
288
|
+
deploy_httpsPortsInvalid: "HTTP \u3068 HTTPS \u306E\u30DD\u30FC\u30C8\u306F\u7570\u306A\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059\u3002Let\u2019s Encrypt HTTP-01 \u306B\u306F\u30DB\u30B9\u30C8\u306E\u30DD\u30FC\u30C8 80 \u304C\u5FC5\u8981\u3067\u3059\u3002",
|
|
289
|
+
deploy_httpsCertificateInvalid: "HTTPS \u8A3C\u660E\u66F8\u304C\u7121\u52B9\u3067\u3059\u3002PEM \u30D5\u30A1\u30A4\u30EB\u3001\u6697\u53F7\u5316\u3055\u308C\u3066\u3044\u306A\u3044\u79D8\u5BC6\u9375\u3068\u306E\u4E00\u81F4\u3001\u6709\u52B9\u671F\u9593\u3001\u30C9\u30E1\u30A4\u30F3\u3092\u78BA\u8A8D\u3057\u3001\u518D\u4F5C\u6210\u3057\u3066\u304F\u3060\u3055\u3044\u3002"
|
|
281
290
|
};
|
|
282
291
|
|
|
283
292
|
// messages/zh-CN.json
|
|
@@ -335,7 +344,7 @@ var zh_CN_default = {
|
|
|
335
344
|
deploy_storageProxyInvalid: "\u65E0\u6CD5\u751F\u6210\u5BF9\u8C61\u5B58\u50A8\u4EE3\u7406\uFF1A\u8BF7\u4F7F\u7528\u6709\u6548\u7684 DNS \u6876\u540D\uFF0C\u5E76\u786E\u4FDD\u6876\u8DEF\u5F84\u4E0D\u4E0E\u7F51\u7AD9\u76EE\u5F55\u3001API \u6216\u5065\u5EB7\u68C0\u67E5\u5165\u53E3\u91CD\u53E0\u3002",
|
|
336
345
|
deploy_storagePublicEndpointInvalid: "storage.publicEndpoint \u65E0\u6548\uFF1A\u8BF7\u586B\u5199\u6D4F\u89C8\u5668\u53EF\u8BBF\u95EE\u7684 HTTP(S) \u5730\u5740\uFF0C\u53EF\u5305\u542B\u89C4\u8303\u7684\u4EE3\u7406\u76EE\u5F55\uFF0C\u4E0D\u80FD\u5305\u542B\u6876\u540D\u3001\u51ED\u636E\u3001\u67E5\u8BE2\u6216\u7247\u6BB5\u3002",
|
|
337
346
|
deploy_storageBackendRequired: "\u9759\u6001\u6D4F\u89C8\u5668\u5E94\u7528\u4E0D\u80FD\u90E8\u7F72\u5B58\u50A8\u670D\u52A1\u7AEF\u914D\u7F6E\u3002\u8BF7\u5728 Serv \u6216 SvelteKit \u670D\u52A1\u4E2D\u914D\u7F6E Storage\uFF0C\u518D\u901A\u8FC7 API \u83B7\u53D6\u9884\u7B7E\u540D\u94FE\u63A5\u3002",
|
|
338
|
-
deploy_artifactInvalid: "\u955C\u50CF\u4EA7\u7269\u7F3A\u5931\u6216\u6821\u9A8C\u5931\u8D25\u3002\u8BF7\u5148\u6267\u884C pnpm build --target
|
|
347
|
+
deploy_artifactInvalid: "\u955C\u50CF\u4EA7\u7269\u7F3A\u5931\u6216\u6821\u9A8C\u5931\u8D25\u3002\u8BF7\u5148\u6267\u884C pnpm build --target image\uFF0C\u6216\u7528 --artifact-dir \u6307\u5B9A\u6709\u6548\u4EA7\u7269\uFF1B\u4E5F\u53EF\u7528 pnpm run deploy --build \u4E00\u6B21\u5B8C\u6210\u3002",
|
|
339
348
|
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",
|
|
340
349
|
deploy_sshPasswordInvalid: "SSH \u5BC6\u7801\u4E0D\u80FD\u4E3A\u7A7A\u6216\u5305\u542B CR/LF/NUL\uFF0CUTF-8 \u957F\u5EA6\u4E0D\u80FD\u8D85\u8FC7 1023 \u5B57\u8282\u3002",
|
|
341
350
|
deploy_sshAskpassFailed: "\u65E0\u6CD5\u51C6\u5907\u53D7\u63A7 SSH \u5BC6\u7801 helper\uFF1B\u8BF7\u68C0\u67E5\u7CFB\u7EDF OpenSSH \u548C Windows PowerShell 5\u3002",
|
|
@@ -385,7 +394,9 @@ var zh_CN_default = {
|
|
|
385
394
|
deploy_secretImportFailed: "\u65E7\u51ED\u636E\u5BFC\u5165\u5728 {phase} \u9636\u6BB5\u88AB\u62D2\u7EDD\uFF1B\u8BF7\u6838\u5BF9\u79C1\u6709\u8F93\u5165\u6743\u9650\u3001\u9879\u76EE/\u5377\u8EAB\u4EFD\u3001\u5B9E\u9645\u670D\u52A1\u51ED\u636E\u53CA\u90E8\u7F72\u9501\uFF0C\u672A\u6267\u884C\u670D\u52A1\u66F4\u65B0\uFF1B\u63D0\u4EA4\u7ED3\u679C\u4E0D\u660E\u65F6\u8BF7\u6838\u5BF9\u53D7\u4FDD\u62A4\u7684\u72B6\u6001\u6587\u4EF6",
|
|
386
395
|
deploy_projectNameRequired: "\u5DE5\u7A0B\u6839 package.json.name \u7F3A\u5931\u6216\u65E0\u6CD5\u5F52\u4E00\u5316\uFF0C\u4E0D\u80FD\u751F\u6210\u955C\u50CF\u4E0E\u90E8\u7F72\u8D44\u6E90\u8EAB\u4EFD\u3002",
|
|
387
396
|
deploy_projectNameOverrideRemoved: "\u90E8\u7F72\u9879\u76EE\u540D\u7EDF\u4E00\u6765\u81EA\u5DE5\u7A0B\u6839 package.json.name\uFF1B\u8BF7\u79FB\u9664\u65E7 projectName \u8986\u76D6\u53C2\u6570\u3002",
|
|
388
|
-
deploy_requestAccessInvalid: "\u8BF7\u6C42\u8BBF\u95EE\u7B56\u7565\u914D\u7F6E\u65E0\u6548\uFF08{field}\uFF09\u3002\u8BF7\u68C0\u67E5\u5B57\u6BB5\u3001IP\u3001\u65F6\u533A\u53CA\u65F6\u95F4\u7A97\u53E3\uFF1B\u5982\u5F15\u7528\u6587\u4EF6\uFF0C\u8BF7\u786E\u8BA4\u6587\u4EF6\u53EF\u8BFB\u3001\u683C\u5F0F\u4E3A YAML \u4E14\u4E0D\u8D85\u8FC7 1 MiB\u3002\u4FEE\u6B63\u540E\u91CD\u65B0\u6253\u5305\u3002"
|
|
397
|
+
deploy_requestAccessInvalid: "\u8BF7\u6C42\u8BBF\u95EE\u7B56\u7565\u914D\u7F6E\u65E0\u6548\uFF08{field}\uFF09\u3002\u8BF7\u68C0\u67E5\u5B57\u6BB5\u3001IP\u3001\u65F6\u533A\u53CA\u65F6\u95F4\u7A97\u53E3\uFF1B\u5982\u5F15\u7528\u6587\u4EF6\uFF0C\u8BF7\u786E\u8BA4\u6587\u4EF6\u53EF\u8BFB\u3001\u683C\u5F0F\u4E3A YAML \u4E14\u4E0D\u8D85\u8FC7 1 MiB\u3002\u4FEE\u6B63\u540E\u91CD\u65B0\u6253\u5305\u3002",
|
|
398
|
+
deploy_httpsPortsInvalid: "HTTP \u4E0E HTTPS \u7AEF\u53E3\u5FC5\u987B\u4E0D\u540C\uFF1BLet\u2019s Encrypt \u7684 HTTP-01 \u5165\u53E3\u5FC5\u987B\u4F7F\u7528\u5BBF\u4E3B\u673A 80 \u7AEF\u53E3\u3002",
|
|
399
|
+
deploy_httpsCertificateInvalid: "HTTPS \u8BC1\u4E66\u65E0\u6548\uFF1A\u8BF7\u6838\u5BF9 PEM \u6587\u4EF6\u53EF\u8BFB\u3001\u8BC1\u4E66\u4E0E\u672A\u52A0\u5BC6\u79C1\u94A5\u5339\u914D\u3001\u8BC1\u4E66\u6709\u6548\u671F\u53CA\u57DF\u540D\uFF0C\u7136\u540E\u91CD\u65B0\u6253\u5305\u3002"
|
|
389
400
|
};
|
|
390
401
|
|
|
391
402
|
// src/deploy-i18n.ts
|
|
@@ -425,12 +436,43 @@ var SshConfigSchema = z.object({
|
|
|
425
436
|
if (!ssh.password || /[\r\n\0]/.test(ssh.password) || new TextEncoder().encode(ssh.password).length > 1023)
|
|
426
437
|
context.addIssue({ code: "custom", path: ["password"], message: deployM("deploy_sshPasswordInvalid") });
|
|
427
438
|
});
|
|
439
|
+
var HttpsDomainSchema = z.string().max(253).regex(/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i).refine((value) => !value.includes(".."));
|
|
440
|
+
var httpsOptions = {
|
|
441
|
+
/** 公开访问域名;同时用于证书身份校验和 Nginx 入口。 */
|
|
442
|
+
domain: HttpsDomainSchema.describe("HTTPS \u516C\u5F00\u57DF\u540D\uFF1B\u8BC1\u4E66\u5FC5\u987B\u5339\u914D\u8BE5\u57DF\u540D"),
|
|
443
|
+
/** HTTPS 宿主端口,与 HTTP 端口不同。 */
|
|
444
|
+
port: z.number().int().min(1).max(65535).default(443).describe("HTTPS \u5BBF\u4E3B\u7AEF\u53E3\uFF0C\u9ED8\u8BA4 443\uFF0C\u987B\u4E0E HTTP \u7AEF\u53E3\u4E0D\u540C"),
|
|
445
|
+
/** 是否将普通 HTTP 请求跳转到 HTTPS。 */
|
|
446
|
+
redirectHttp: z.boolean().default(true).describe("\u666E\u901A HTTP \u8BF7\u6C42\u662F\u5426\u4EE5 308 \u8DF3\u8F6C\u81F3 HTTPS\uFF0C\u5065\u5EB7\u68C0\u67E5\u548C ACME \u6311\u6218\u9664\u5916")
|
|
447
|
+
};
|
|
448
|
+
var HttpsConfigSchema = z.discriminatedUnion("type", [
|
|
449
|
+
z.object({
|
|
450
|
+
type: z.literal("files"),
|
|
451
|
+
...httpsOptions,
|
|
452
|
+
/** 相对于工程根或绝对路径的 PEM 完整证书链及未加密私钥。 */
|
|
453
|
+
certificateFile: z.string().min(1).describe("PEM \u5B8C\u6574\u8BC1\u4E66\u94FE\u8DEF\u5F84\uFF0C\u76F8\u5BF9\u5DE5\u7A0B\u6839\u6216\u7EDD\u5BF9\u8DEF\u5F84\uFF1B\u6253\u5305\u65F6\u590D\u5236\u5230\u79C1\u6709\u53D1\u5E03\u76EE\u5F55"),
|
|
454
|
+
privateKeyFile: z.string().min(1).describe("\u672A\u52A0\u5BC6 PEM \u79C1\u94A5\u8DEF\u5F84\uFF0C\u76F8\u5BF9\u5DE5\u7A0B\u6839\u6216\u7EDD\u5BF9\u8DEF\u5F84\uFF1B\u5FC5\u987B\u4E0E\u8BC1\u4E66\u5339\u914D")
|
|
455
|
+
}),
|
|
456
|
+
z.object({
|
|
457
|
+
type: z.literal("letsencrypt"),
|
|
458
|
+
...httpsOptions,
|
|
459
|
+
domain: HttpsDomainSchema.refine((value) => value.includes(".") && !/^\d+(?:\.\d+){3}$/.test(value)).describe("Let\u2019s Encrypt \u5355\u4E2A\u516C\u7F51\u57DF\u540D\uFF0C\u4F7F\u7528 HTTP-01\uFF0C\u4E0D\u652F\u6301 IP \u6216\u901A\u914D\u7B26"),
|
|
460
|
+
email: z.email().describe("Let\u2019s Encrypt \u8D26\u53F7\u8054\u7CFB\u90AE\u7BB1"),
|
|
461
|
+
/** 使用者须明确同意 Let's Encrypt 的服务条款。 */
|
|
462
|
+
acceptTerms: z.literal(true).describe("\u660E\u786E\u540C\u610F Let\u2019s Encrypt \u670D\u52A1\u6761\u6B3E\u540E\u624D\u53EF\u8BBE\u7F6E\u4E3A true")
|
|
463
|
+
})
|
|
464
|
+
]);
|
|
428
465
|
var PortExposeSchema = z.object({
|
|
429
466
|
type: z.literal("port").default("port"),
|
|
430
467
|
/** 容器内监听端口(adapter-node 默认 3000) */
|
|
431
468
|
containerPort: z.number().int().min(1).max(65535).default(3e3),
|
|
432
469
|
/** 宿主机映射端口 */
|
|
433
|
-
hostPort: z.number().int().min(1).max(65535)
|
|
470
|
+
hostPort: z.number().int().min(1).max(65535),
|
|
471
|
+
/** 可选 HTTPS:已有 PEM 文件或官方 Certbot 自动申请续期;省略保留 HTTP。 */
|
|
472
|
+
https: HttpsConfigSchema.optional().describe("\u53EF\u9009 HTTPS \u5165\u53E3\uFF1Bfiles \u4F7F\u7528\u5DF2\u6709\u8BC1\u4E66\uFF0Cletsencrypt \u4F7F\u7528\u5B98\u65B9 Certbot \u7533\u8BF7\u7EED\u671F\uFF0C\u7701\u7565\u4FDD\u6301 HTTP")
|
|
473
|
+
}).superRefine((expose, context) => {
|
|
474
|
+
if (expose.https && (expose.https.port === expose.hostPort || expose.https.type === "letsencrypt" && expose.hostPort !== 80))
|
|
475
|
+
context.addIssue({ code: "custom", path: ["https"], message: deployM("deploy_httpsPortsInvalid") });
|
|
434
476
|
});
|
|
435
477
|
var ExposeSchema = PortExposeSchema;
|
|
436
478
|
var DockerSshProviderConfigSchema = z.object({
|
|
@@ -929,6 +971,32 @@ function saveCredentials(entries) {
|
|
|
929
971
|
);
|
|
930
972
|
}
|
|
931
973
|
}
|
|
974
|
+
|
|
975
|
+
// src/deploy-middleware-config.ts
|
|
976
|
+
var connectionFields = {
|
|
977
|
+
db: ["url", "host", "port", "database", "user", "password", "ssl"],
|
|
978
|
+
cache: ["url", "host", "port", "password", "cluster", "sentinel", "tls"],
|
|
979
|
+
storage: ["endpoint", "bucket", "region", "accessKeyId", "secretAccessKey", "forcePathStyle", "createBucket"],
|
|
980
|
+
vecdb: ["url", "host", "port", "database", "user", "password", "apiKey", "path", "serverCommand", "startupTimeout"],
|
|
981
|
+
ldap: ["url", "bindDn", "bindPassword", "searchBase", "useTls"],
|
|
982
|
+
email: ["host", "port", "secure", "user", "pass"]
|
|
983
|
+
};
|
|
984
|
+
function mergeManagedMiddlewareConfig(requirement, supplied) {
|
|
985
|
+
if (!requirement || requirement.service !== "ldap" && requirement.backend !== supplied.type)
|
|
986
|
+
return supplied;
|
|
987
|
+
const configured = { ...requirement.config };
|
|
988
|
+
for (const field2 of connectionFields[requirement.service] ?? [])
|
|
989
|
+
delete configured[field2];
|
|
990
|
+
const merged = { ...configured, ...supplied };
|
|
991
|
+
if (requirement.service === "cache" && typeof configured.db === "number" && typeof supplied.url === "string") {
|
|
992
|
+
const url = new URL(supplied.url);
|
|
993
|
+
if (configured.db !== 0 || url.pathname !== "" && url.pathname !== "/") {
|
|
994
|
+
url.pathname = String(configured.db);
|
|
995
|
+
merged.url = url.toString();
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
return merged;
|
|
999
|
+
}
|
|
932
1000
|
var RecoverySchema = z.object({
|
|
933
1001
|
projectName: z.string(),
|
|
934
1002
|
stage: z.string(),
|
|
@@ -1796,7 +1864,7 @@ async function groupExited(pid) {
|
|
|
1796
1864
|
async function helper(command, args, timeoutMs) {
|
|
1797
1865
|
if (timeoutMs <= 0)
|
|
1798
1866
|
return false;
|
|
1799
|
-
return new Promise((
|
|
1867
|
+
return new Promise((resolve9) => {
|
|
1800
1868
|
const child = spawn(command, args, { windowsHide: true, shell: false, stdio: "ignore" });
|
|
1801
1869
|
let settled = false;
|
|
1802
1870
|
let timer;
|
|
@@ -1806,7 +1874,7 @@ async function helper(command, args, timeoutMs) {
|
|
|
1806
1874
|
settled = true;
|
|
1807
1875
|
if (timer)
|
|
1808
1876
|
clearTimeout(timer);
|
|
1809
|
-
|
|
1877
|
+
resolve9(success);
|
|
1810
1878
|
};
|
|
1811
1879
|
timer = globalThis.setTimeout(() => {
|
|
1812
1880
|
child.kill("SIGKILL");
|
|
@@ -1949,7 +2017,7 @@ ${deployM(state.localTermination === "confirmed" ? "deploy_localStopped" : state
|
|
|
1949
2017
|
ext: { commandExecution: state }
|
|
1950
2018
|
});
|
|
1951
2019
|
try {
|
|
1952
|
-
return await new Promise((
|
|
2020
|
+
return await new Promise((resolve9) => {
|
|
1953
2021
|
if (typeof command !== "string" || !command || command.includes("\0") || args.some((argument) => typeof argument !== "string" || argument.includes("\0")))
|
|
1954
2022
|
throw new TypeError("Invalid command arguments");
|
|
1955
2023
|
if (options.input !== void 0 && typeof options.input !== "string")
|
|
@@ -1984,7 +2052,7 @@ ${deployM(state.localTermination === "confirmed" ? "deploy_localStopped" : state
|
|
|
1984
2052
|
settled = true;
|
|
1985
2053
|
if (timer)
|
|
1986
2054
|
clearTimeout(timer);
|
|
1987
|
-
|
|
2055
|
+
resolve9(result);
|
|
1988
2056
|
};
|
|
1989
2057
|
const interrupt = (reason, message) => {
|
|
1990
2058
|
interrupted = true;
|
|
@@ -2433,7 +2501,7 @@ start_services() {
|
|
|
2433
2501
|
application_services=''
|
|
2434
2502
|
application_orphan_option=$orphan_option
|
|
2435
2503
|
for service in "$@"; do
|
|
2436
|
-
case "$service" in serv|web|gateway) ;; *) middleware_services="$middleware_services $service";; esac
|
|
2504
|
+
case "$service" in serv|web|gateway|certbot) ;; *) middleware_services="$middleware_services $service";; esac
|
|
2437
2505
|
done
|
|
2438
2506
|
if [ -n "$middleware_services" ]; then
|
|
2439
2507
|
compose up -d --no-deps --no-build --pull never "$recreate_option" $orphan_option $middleware_services || return 1
|
|
@@ -2449,6 +2517,9 @@ start_services() {
|
|
|
2449
2517
|
fi
|
|
2450
2518
|
case " $* " in *' gateway '*) compose up -d --no-deps --no-build --pull never "$recreate_option" gateway || return 1;; esac
|
|
2451
2519
|
}
|
|
2520
|
+
start_renewal() {
|
|
2521
|
+
case " $* " in *' certbot '*) compose up -d --no-deps --no-build --pull never --no-recreate certbot || return 1;; esac
|
|
2522
|
+
}
|
|
2452
2523
|
read_proxy_service() {
|
|
2453
2524
|
proxy_directory=$1
|
|
2454
2525
|
if [ -e "$proxy_directory/proxy-service" ] || [ -L "$proxy_directory/proxy-service" ]; then
|
|
@@ -2472,13 +2543,22 @@ reload_retained_proxy() {
|
|
|
2472
2543
|
esac
|
|
2473
2544
|
}
|
|
2474
2545
|
wait_http() {
|
|
2475
|
-
|
|
2476
|
-
|
|
2546
|
+
http_release=$1
|
|
2547
|
+
http_port=$2
|
|
2548
|
+
shift 2
|
|
2549
|
+
https_domain=''
|
|
2550
|
+
if [ -f "$http_release/https" ]; then
|
|
2551
|
+
read -r https_domain https_port https_type < "$http_release/https"
|
|
2552
|
+
https_certificates="$http_release/tls"
|
|
2553
|
+
if [ "$https_type" = letsencrypt ]; then https_certificates="$root/https/certificates/$https_domain"; fi
|
|
2554
|
+
fi
|
|
2477
2555
|
for http_path in "$@"; do
|
|
2478
2556
|
http_ready=false
|
|
2479
2557
|
http_attempt=0
|
|
2480
2558
|
while [ "$http_attempt" -lt 40 ]; do
|
|
2481
|
-
if
|
|
2559
|
+
if [ -n "$https_domain" ]; then
|
|
2560
|
+
if curl --noproxy '*' -fsS --max-time 5 --cacert "$https_certificates/fullchain.pem" --resolve "$https_domain:$https_port:127.0.0.1" "https://$https_domain:$https_port$http_path" >/dev/null 2>&1; then http_ready=true; break; fi
|
|
2561
|
+
elif curl --noproxy '*' -fsS --max-time 5 "http://127.0.0.1:$http_port$http_path" >/dev/null 2>&1; then http_ready=true; break; fi
|
|
2482
2562
|
http_attempt=$((http_attempt + 1))
|
|
2483
2563
|
sleep 3
|
|
2484
2564
|
done
|
|
@@ -2491,7 +2571,6 @@ verify_recovery() {
|
|
|
2491
2571
|
recovery_port=$(awk -F: 'NR == 1 { print $1 }' "$recovery_release/layout") || return 1
|
|
2492
2572
|
recovery_paths=''
|
|
2493
2573
|
if [ -f "$recovery_release/health-paths" ]; then
|
|
2494
|
-
recovery_port=$(cat "$recovery_release/health-port") || return 1
|
|
2495
2574
|
recovery_paths=$(cat "$recovery_release/health-paths") || return 1
|
|
2496
2575
|
else
|
|
2497
2576
|
# 旧格式发布没有独立健康文件;按已存拓扑恢复最小 HTTP 检查。
|
|
@@ -2501,10 +2580,10 @@ verify_recovery() {
|
|
|
2501
2580
|
elif [ "$recovery_primary" = web ]; then recovery_paths='/'; else recovery_paths='/health'; fi
|
|
2502
2581
|
fi
|
|
2503
2582
|
case "$recovery_port" in ''|*[!0-9]*) return 1;; esac
|
|
2504
|
-
test -n "$recovery_paths" || return 1
|
|
2505
2583
|
wait_services $recover_selected || return 1
|
|
2584
|
+
start_renewal $recover_selected || return 1
|
|
2506
2585
|
reload_retained_proxy "$recovery_release" || return 1
|
|
2507
|
-
wait_http "$recovery_port" $recovery_paths
|
|
2586
|
+
wait_http "$recovery_release" "$recovery_port" $recovery_paths
|
|
2508
2587
|
}
|
|
2509
2588
|
finish() {
|
|
2510
2589
|
code=$?
|
|
@@ -2632,7 +2711,7 @@ if [ "$partial" = true ]; then
|
|
|
2632
2711
|
test "$current_storage_routing" = "$previous_storage_routing" || { printf 'Managed storage configuration changed. Create a full deployment to switch its public endpoint, bucket, or access policy consistently.\n' >&2; exit 1; }
|
|
2633
2712
|
if ! cmp -s layout "$root/releases/$previous/layout"; then
|
|
2634
2713
|
# 容量调整允许复用已部署 Web 镜像;端口、路由及其余代理策略仍要求全量部署。
|
|
2635
|
-
test "$(awk -F: '{print $1 ":" $2 ":" $3 ":" $4}' layout)" = "$(awk -F: '{print $1 ":" $2 ":" $3 ":" $4}' "$root/releases/$previous/layout")" || { printf 'Ports or application layout changed. Create a full deployment bundle.\n' >&2; exit 1; }
|
|
2714
|
+
test "$(awk -F: '{print $1 ":" $2 ":" $3 ":" $4 ":" $6}' layout)" = "$(awk -F: '{print $1 ":" $2 ":" $3 ":" $4 ":" $6}' "$root/releases/$previous/layout")" || { printf 'Ports, HTTPS or application layout changed. Create a full deployment bundle.\n' >&2; exit 1; }
|
|
2636
2715
|
test -f web.conf && test -f proxy-entry.json || { printf 'Proxy configuration missing. Create a full deployment bundle.\n' >&2; exit 1; }
|
|
2637
2716
|
previous_proxy=
|
|
2638
2717
|
while IFS= read -r compose_file; do
|
|
@@ -2650,9 +2729,17 @@ if [ "$partial" = true ]; then
|
|
|
2650
2729
|
fi
|
|
2651
2730
|
fi
|
|
2652
2731
|
mkdir -p "$release"
|
|
2653
|
-
|
|
2732
|
+
# 镜像直接从本次解包目录导入;发布记录只保存运行配置与恢复证据。
|
|
2733
|
+
for entry in "$source_dir"/* "$source_dir"/.[!.]*; do
|
|
2734
|
+
test -e "$entry" || continue
|
|
2735
|
+
test "$entry" != "$source_dir/images" || continue
|
|
2736
|
+
cp -R "$entry" "$release/"
|
|
2737
|
+
done
|
|
2654
2738
|
# Nginx 以非 root 用户运行;只开放不含凭据的代理配置读取权限。
|
|
2655
2739
|
if [ -f "$release/web.conf" ]; then chmod 644 "$release/web.conf"; fi
|
|
2740
|
+
for https_script in https-start.sh renew-https.sh; do
|
|
2741
|
+
if [ -f "$release/$https_script" ]; then chmod 444 "$release/$https_script"; fi
|
|
2742
|
+
done
|
|
2656
2743
|
# 策略不是凭据,发布目录保持700;只读挂载允许镜像中的非root应用读取。
|
|
2657
2744
|
if [ -f "$release/request-access.yml" ]; then chmod 444 "$release/request-access.yml"; fi
|
|
2658
2745
|
: > "$release/compose-files"
|
|
@@ -2666,6 +2753,12 @@ if [ "$partial" = true ]; then
|
|
|
2666
2753
|
fi
|
|
2667
2754
|
step credentials 'Resolving server-managed middleware credentials'
|
|
2668
2755
|
sh "$release/resolve-managed-secrets.sh" "$root" "$release" "$runtime"
|
|
2756
|
+
if [ -f "$release/runtime-env.mjs" ]; then
|
|
2757
|
+
# 上级目录保护宿主秘密;只读文件挂载允许非 root 应用读取,宿主所有者仍可修改配置。
|
|
2758
|
+
chmod 700 "$root" "$release"
|
|
2759
|
+
chmod 644 "$release/.env.runtime" "$release/middleware.env"
|
|
2760
|
+
chmod 444 "$release/runtime-env.mjs"
|
|
2761
|
+
fi
|
|
2669
2762
|
# 在任何可能启动服务(包括增量恢复旧拓扑)之前定稿,失败回滚不删除卷的引擎身份。
|
|
2670
2763
|
sh "$release/resolve-backend-state.sh" "$root" "$release" "$runtime" "$previous" commit
|
|
2671
2764
|
printf '%s\n' "$release/compose-entry.json" >> "$release/compose-files"
|
|
@@ -2703,12 +2796,15 @@ step images 'Loading packaged images (no registry access)'
|
|
|
2703
2796
|
while IFS=' ' read -r archive reference; do
|
|
2704
2797
|
test -n "$archive" || continue
|
|
2705
2798
|
images_changed=true
|
|
2706
|
-
"$runtime" load -i "$
|
|
2799
|
+
"$runtime" load -i "$source_dir/images/$archive"
|
|
2707
2800
|
"$runtime" image inspect "$reference" >/dev/null
|
|
2708
2801
|
architecture=$("$runtime" image inspect --format '{{.Architecture}}' "$reference")
|
|
2709
2802
|
host_arch=$(uname -m)
|
|
2710
2803
|
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
|
|
2711
2804
|
done < "$release/image-list"
|
|
2805
|
+
if [ -f "$release/prepare-https.sh" ]; then
|
|
2806
|
+
sh "$release/prepare-https.sh" "$root" "$release" "$runtime" prepare "$partial"
|
|
2807
|
+
fi
|
|
2712
2808
|
step dependencies 'Checking dependencies of selected services'
|
|
2713
2809
|
if [ "$partial" = true ]; then cat "$release/services" >> "$release/dependencies"; fi
|
|
2714
2810
|
while IFS= read -r dependency; do
|
|
@@ -2720,9 +2816,18 @@ step start 'Updating selected services'
|
|
|
2720
2816
|
changed=true
|
|
2721
2817
|
remove_selected $selected
|
|
2722
2818
|
start_services $selected
|
|
2819
|
+
if [ -f "$release/prepare-https.sh" ] && [ "$partial" = false ]; then
|
|
2820
|
+
step https 'Issuing or installing HTTPS certificate'
|
|
2821
|
+
tls_proxy=$(read_proxy_service "$release")
|
|
2822
|
+
wait_services "$tls_proxy"
|
|
2823
|
+
sh "$release/prepare-https.sh" "$root" "$release" "$runtime" issue "$partial"
|
|
2824
|
+
compose exec -T "$tls_proxy" nginx -t
|
|
2825
|
+
compose exec -T "$tls_proxy" nginx -s reload
|
|
2826
|
+
fi
|
|
2827
|
+
start_renewal $selected
|
|
2723
2828
|
reload_retained_proxy "$release"
|
|
2724
2829
|
step health 'Checking HTTP access'
|
|
2725
|
-
wait_http @@PORT@@ @@HEALTH@@
|
|
2830
|
+
wait_http "$release" @@PORT@@ @@HEALTH@@
|
|
2726
2831
|
step complete 'Deployment passed all checks'
|
|
2727
2832
|
printf '%s\n' "$id" > "$root/current.next"
|
|
2728
2833
|
mv "$root/current.next" "$root/current"
|
|
@@ -2776,7 +2881,7 @@ case "$timeout" in ''|*[!0-9]*|???????????*) invalid_target;; esac
|
|
|
2776
2881
|
test "$timeout" -ge 1 && test "$timeout" -le 2147483647 || invalid_target
|
|
2777
2882
|
case "$remote" in /*) ;; *) invalid_target;; esac
|
|
2778
2883
|
case "$remote" in *[!a-zA-Z0-9/_.\ -]*|*/../*|*/..|/..) invalid_target;; esac
|
|
2779
|
-
case "$bundle" in *.tar) ;; *) invalid_target;; esac
|
|
2884
|
+
case "$bundle" in *.tar|*.tar.gz) ;; *) invalid_target;; esac
|
|
2780
2885
|
case "$bundle" in ''|*[!a-zA-Z0-9_.+-]*) invalid_target;; esac
|
|
2781
2886
|
case "$strict" in true) strict=accept-new;; false) strict=no;; *) invalid_target;; esac
|
|
2782
2887
|
test -f "./$bundle" || { printf 'Bundle missing: %s\n' "$bundle" >&2; exit 1; }
|
|
@@ -2900,7 +3005,7 @@ monitor=''
|
|
|
2900
3005
|
if [ "$upload_code" -ne 0 ]; then printf '[FAIL] Upload failed (exit %s). Check SSH output above.\n' "$upload_code" >&2; exit "$upload_code"; fi
|
|
2901
3006
|
printf '[UPLOAD] 100%% (%s/%s bytes)\n' "$total" "$total"
|
|
2902
3007
|
printf '[3/3] Applying package and checking HTTP access\n'
|
|
2903
|
-
if run_native ssh "$@" -p "$port" "$user@$host" "cd '$remote/incoming' && mkdir -p '$bundle.contents' && tar -xf '$bundle' -C '$bundle.contents' && sh '$bundle.contents/apply.sh' '$remote'"; then
|
|
3008
|
+
if run_native ssh "$@" -p "$port" "$user@$host" "cd '$remote/incoming' && mkdir -p '$bundle.contents' && tar -xf '$bundle' -C '$bundle.contents' && sh '$bundle.contents/apply.sh' '$remote' && { rm -rf -- '$bundle.contents' && rm -f -- '$bundle' || printf '[WARN] Deployment passed; incoming archive cleanup failed\\n' >&2; }"; then
|
|
2904
3009
|
:
|
|
2905
3010
|
else
|
|
2906
3011
|
code=$?
|
|
@@ -2935,7 +3040,7 @@ if ($windowsRuntime) {
|
|
|
2935
3040
|
Set-Location -LiteralPath $PSScriptRoot
|
|
2936
3041
|
$target = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'deploy-target.json') -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
2937
3042
|
if ($target.host -eq 'SERVER_HOST') { throw 'Set the server host in deploy-target.json.' }
|
|
2938
|
-
if ($target.host -isnot [string] -or $target.host -notmatch '^[a-zA-Z0-9][a-zA-Z0-9.:-]*$' -or $target.username -isnot [string] -or $target.username -notmatch '^[a-zA-Z_][a-zA-Z0-9_-]*$' -or $target.directory -isnot [string] -or $target.directory -notmatch '^/[a-zA-Z0-9/_. -]*$' -or $target.directory -match '(^|/)\.\.(/|$)' -or $target.bundle -isnot [string] -or $target.bundle -notmatch '^[a-zA-Z0-9_.+-]+\.tar
|
|
3043
|
+
if ($target.host -isnot [string] -or $target.host -notmatch '^[a-zA-Z0-9][a-zA-Z0-9.:-]*$' -or $target.username -isnot [string] -or $target.username -notmatch '^[a-zA-Z_][a-zA-Z0-9_-]*$' -or $target.directory -isnot [string] -or $target.directory -notmatch '^/[a-zA-Z0-9/_. -]*$' -or $target.directory -match '(^|/)\.\.(/|$)' -or $target.bundle -isnot [string] -or $target.bundle -notmatch '^[a-zA-Z0-9_.+-]+\.tar(\.gz)?$') { throw 'Invalid deployment target.' }
|
|
2939
3044
|
$port = if ($null -eq $target.port) { 22 } else { $target.port }
|
|
2940
3045
|
$timeout = if ($null -eq $target.connectTimeoutSeconds) { 10 } else { $target.connectTimeoutSeconds }
|
|
2941
3046
|
if ($port -is [string] -or [string]$port -notmatch '^[0-9]+$' -or $port -lt 1 -or $port -gt 65535 -or $timeout -is [string] -or [string]$timeout -notmatch '^[0-9]+$' -or $timeout -lt 1 -or $timeout -gt 2147483647) { throw 'Invalid deployment port or timeout.' }
|
|
@@ -3094,7 +3199,7 @@ try {
|
|
|
3094
3199
|
Invoke-Step '2/3 Upload' 'scp' ($options + @('-P', [string]$port, "./$bundle", "$($scpDestination):$remote/incoming/$bundle"))
|
|
3095
3200
|
Write-Host "[UPLOAD] 100% ($uploadBytes/$uploadBytes bytes)"
|
|
3096
3201
|
$applying = $true
|
|
3097
|
-
Invoke-Step '3/3 Apply and verify HTTP' 'ssh' ($options + @('-p', [string]$port, $destination, "cd '$remote/incoming' && mkdir -p '$bundle.contents' && tar -xf '$bundle' -C '$bundle.contents' && sh '$bundle.contents/apply.sh' '$remote'"))
|
|
3202
|
+
Invoke-Step '3/3 Apply and verify HTTP' 'ssh' ($options + @('-p', [string]$port, $destination, "cd '$remote/incoming' && mkdir -p '$bundle.contents' && tar -xf '$bundle' -C '$bundle.contents' && sh '$bundle.contents/apply.sh' '$remote' && { rm -rf -- '$bundle.contents' && rm -f -- '$bundle' || printf '[WARN] Deployment passed; incoming archive cleanup failed\\n' >&2; }"))
|
|
3098
3203
|
} catch {
|
|
3099
3204
|
if ($applying) {
|
|
3100
3205
|
if ($LASTEXITCODE -gt 0 -and $LASTEXITCODE -lt 128) { [Console]::Error.WriteLine("[FAIL] Remote deployment failed (exit $LASTEXITCODE). Inspect $remote/result-<deploymentId>.json (ID in README.txt) and startup diagnostics.") }
|
|
@@ -3263,13 +3368,14 @@ ${removed.error.message}` });
|
|
|
3263
3368
|
async function createDeploymentBundle(options) {
|
|
3264
3369
|
const { directory, artifactDir, artifact, projectName, environment, config, selected } = options;
|
|
3265
3370
|
const output = resolve(options.output);
|
|
3266
|
-
if (!/^[\w.+-]+\.tar
|
|
3371
|
+
if (!/^[\w.+-]+\.tar(?:\.gz)?$/.test(basename(output)))
|
|
3267
3372
|
throw new Error(deployM("deploy_bundleNameInvalid"));
|
|
3268
3373
|
const staging = mkdtempSync(join(directory, ".bundle-"));
|
|
3269
3374
|
const temporaryTar = `${output}.partial`;
|
|
3270
3375
|
mkdirSync(dirname(output), { recursive: true, mode: 448 });
|
|
3271
3376
|
chmodSync(staging, 448);
|
|
3272
3377
|
const files = [];
|
|
3378
|
+
const fileHashes = /* @__PURE__ */ new Map();
|
|
3273
3379
|
const put = (file, content) => {
|
|
3274
3380
|
mkdirSync(dirname(join(staging, file)), { recursive: true });
|
|
3275
3381
|
writeFileSync(join(staging, file), content, { mode: 384 });
|
|
@@ -3294,7 +3400,7 @@ async function createDeploymentBundle(options) {
|
|
|
3294
3400
|
service.env_file = name === artifact.primaryService ? [`../${options.deploymentId}/.env.runtime`, `../${options.deploymentId}/middleware.env`] : [`../${options.deploymentId}/middleware-runtime.env`];
|
|
3295
3401
|
}
|
|
3296
3402
|
if (Array.isArray(service.volumes))
|
|
3297
|
-
service.volumes = service.volumes.map((mount) => typeof mount === "string" ? mount.replace(
|
|
3403
|
+
service.volumes = service.volumes.map((mount) => typeof mount === "string" ? mount.replace(/\.\/(web\.conf|request-access\.yml|tls|https-start\.sh|renew-https\.sh|\.env\.runtime|middleware\.env|runtime-env\.mjs):/g, `../${options.deploymentId}/$1:`) : mount);
|
|
3298
3404
|
}
|
|
3299
3405
|
put("compose.json", JSON.stringify({ ...compose, services: Object.fromEntries(selected.map((name) => [name, compose.services[name]])) }));
|
|
3300
3406
|
put("compose-entry.json", JSON.stringify({ ...compose, services: Object.fromEntries(selected.map((name) => [name, { extends: { file: `../${options.deploymentId}/compose.json`, service: name } }])) }));
|
|
@@ -3305,8 +3411,12 @@ async function createDeploymentBundle(options) {
|
|
|
3305
3411
|
throw new Error(deployM("deploy_imageVersionRequired", { params: { image: reference } }));
|
|
3306
3412
|
const built = artifact.images.find((image) => image.imageRef === reference);
|
|
3307
3413
|
const file = built?.file ?? `middleware-${imageList.length + 1}.tar`;
|
|
3414
|
+
const destination = join(staging, "images", file);
|
|
3308
3415
|
if (built) {
|
|
3309
|
-
|
|
3416
|
+
const checksum = await copyArchive(join(artifactDir, file), destination);
|
|
3417
|
+
if (checksum !== built.sha256)
|
|
3418
|
+
throw new Error(deployM("deploy_artifactInvalid"));
|
|
3419
|
+
fileHashes.set(`images/${file}`, checksum);
|
|
3310
3420
|
} else {
|
|
3311
3421
|
if (!runtime) {
|
|
3312
3422
|
const candidates = options.runtime === "auto" ? ["podman", "docker"] : [options.runtime];
|
|
@@ -3319,14 +3429,41 @@ async function createDeploymentBundle(options) {
|
|
|
3319
3429
|
if (!runtime)
|
|
3320
3430
|
throw new Error(deployM("deploy_bundleRuntimeRequired"));
|
|
3321
3431
|
}
|
|
3322
|
-
|
|
3432
|
+
let inspected = await runCommand(runtime, ["image", "inspect", "--format", "{{.Id}}", reference]);
|
|
3433
|
+
if (!inspected.success) {
|
|
3323
3434
|
const pulled = await runCommand(runtime, ["pull", reference], { timeoutMs: 6e5 });
|
|
3324
3435
|
if (!pulled.success)
|
|
3325
3436
|
throw new Error(pulled.error.message);
|
|
3437
|
+
inspected = await runCommand(runtime, ["image", "inspect", "--format", "{{.Id}}", reference]);
|
|
3326
3438
|
}
|
|
3327
|
-
const
|
|
3328
|
-
|
|
3329
|
-
|
|
3439
|
+
const identity = inspected.success && !inspected.data.stdoutTruncated ? inspected.data.stdout.trim().replace(/^sha256:/, "") : "";
|
|
3440
|
+
const cacheIdentity = createHash("sha256").update(identity).update("\0").update(reference).digest("hex");
|
|
3441
|
+
const cache = options.cacheDirectory && /^[a-f0-9]{64}$/.test(identity) ? join(options.cacheDirectory, `${runtime}-${cacheIdentity}.tar`) : void 0;
|
|
3442
|
+
let checksum;
|
|
3443
|
+
if (cache && existsSync(cache) && existsSync(`${cache}.sha256`) && lstatSync(cache).isFile() && !lstatSync(cache).isSymbolicLink()) {
|
|
3444
|
+
const copied = await copyArchive(cache, destination);
|
|
3445
|
+
if (copied === readFileSync(`${cache}.sha256`, "utf8").trim())
|
|
3446
|
+
checksum = copied;
|
|
3447
|
+
}
|
|
3448
|
+
if (!checksum) {
|
|
3449
|
+
const saved = await runCommand(runtime, ["save", ...runtime === "podman" ? ["--format", "docker-archive"] : [], "-o", destination, reference], { timeoutMs: 6e5 });
|
|
3450
|
+
if (!saved.success)
|
|
3451
|
+
throw new Error(saved.error.message);
|
|
3452
|
+
checksum = await artifactChecksum(destination);
|
|
3453
|
+
if (cache) {
|
|
3454
|
+
mkdirSync(dirname(cache), { recursive: true, mode: 448 });
|
|
3455
|
+
const temporary = `${cache}.${basename(staging)}`;
|
|
3456
|
+
try {
|
|
3457
|
+
copyFileSync(destination, temporary);
|
|
3458
|
+
renameSync(temporary, cache);
|
|
3459
|
+
writeFileSync(`${cache}.sha256`, `${checksum}
|
|
3460
|
+
`, { mode: 384 });
|
|
3461
|
+
} finally {
|
|
3462
|
+
rmSync(temporary, { force: true });
|
|
3463
|
+
}
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
fileHashes.set(`images/${file}`, checksum);
|
|
3330
3467
|
}
|
|
3331
3468
|
files.push(`images/${file}`);
|
|
3332
3469
|
imageList.push(`${file} ${reference}`);
|
|
@@ -3335,6 +3472,8 @@ async function createDeploymentBundle(options) {
|
|
|
3335
3472
|
if (selected.includes(artifact.primaryService) && existsSync(join(directory, "request-access.yml")))
|
|
3336
3473
|
put("request-access.yml", readFileSync(join(directory, "request-access.yml"), "utf8"));
|
|
3337
3474
|
put("middleware.env", options.middlewareEnv);
|
|
3475
|
+
if (selected.includes(artifact.primaryService) && !compose.services[artifact.primaryService]?.env_file)
|
|
3476
|
+
put("runtime-env.mjs", readFileSync(new URL("./deploy-runtime-env.mjs", import.meta.url), "utf8"));
|
|
3338
3477
|
const middlewareRuntime = join(directory, "middleware-runtime.env");
|
|
3339
3478
|
const nativeEnvironment = existsSync(middlewareRuntime) ? readFileSync(middlewareRuntime, "utf8") : "";
|
|
3340
3479
|
put("middleware-runtime.env", nativeEnvironment);
|
|
@@ -3342,18 +3481,17 @@ async function createDeploymentBundle(options) {
|
|
|
3342
3481
|
put(file, content);
|
|
3343
3482
|
if (options.hasWebConfig)
|
|
3344
3483
|
put("web.conf", readFileSync(join(directory, "web.conf"), "utf8"));
|
|
3484
|
+
for (const [file, content] of Object.entries(options.httpsAssets ?? {}))
|
|
3485
|
+
put(file, content);
|
|
3345
3486
|
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)}
|
|
3346
3487
|
`);
|
|
3347
3488
|
if (options.hasWebConfig) {
|
|
3348
3489
|
put("proxy-entry.json", JSON.stringify({ services: { [options.proxyService ?? "web"]: { volumes: [`../${options.deploymentId}/web.conf:/etc/nginx/conf.d/default.conf:ro`] } } }));
|
|
3349
3490
|
}
|
|
3350
|
-
put("proxy-service", `${options.hasWebConfig ? options.proxyService ?? "web" : ""}
|
|
3351
|
-
`);
|
|
3352
3491
|
put("storage-routing", `${options.storageRouting ?? ""}
|
|
3353
3492
|
`);
|
|
3354
|
-
|
|
3355
|
-
`
|
|
3356
|
-
put("health-port", `${config.expose.hostPort}
|
|
3493
|
+
const httpsFingerprint = config.expose.https ? createHash("sha256").update(JSON.stringify({ ...config.expose.https, certificateFile: void 0, privateKeyFile: void 0, certificate: options.httpsAssets?.["tls/fullchain.pem"] })).digest("hex") : "";
|
|
3494
|
+
put("layout", `${config.expose.hostPort}:${options.containerPort}:${options.hasWebConfig ? options.proxyService === "gateway" ? "gateway" : "true" : "false"}:${artifact.primaryService}:${options.hasWebConfig ? await artifactChecksum(join(directory, "web.conf")) : ""}${httpsFingerprint ? `:${httpsFingerprint}` : ""}
|
|
3357
3495
|
`);
|
|
3358
3496
|
put("web-base-path", `${artifact.webBasePath ?? ""}
|
|
3359
3497
|
`);
|
|
@@ -3371,15 +3509,15 @@ async function createDeploymentBundle(options) {
|
|
|
3371
3509
|
const selection = deployM("deploy_bundleSelection", { params: { mode, services: selected.join(", ") } });
|
|
3372
3510
|
const instructions = `${selection}
|
|
3373
3511
|
${deployM("deploy_bundleInstructions", { params: instructionParams })}`;
|
|
3374
|
-
|
|
3512
|
+
const readme = `${deployM("deploy_bundleMetadata", { params: { version: artifact.appVersion, buildId: artifact.buildId, deploymentId: options.deploymentId, compose: "compose.json" } })}
|
|
3375
3513
|
${instructions}
|
|
3376
|
-
|
|
3514
|
+
`;
|
|
3377
3515
|
const checksums = [];
|
|
3378
3516
|
for (const file of files)
|
|
3379
|
-
checksums.push(`${await artifactChecksum(join(staging, file))} ${file}`);
|
|
3517
|
+
checksums.push(`${fileHashes.get(file) ?? await artifactChecksum(join(staging, file))} ${file}`);
|
|
3380
3518
|
writeFileSync(join(staging, "SHA256SUMS"), `${checksums.join("\n")}
|
|
3381
3519
|
`);
|
|
3382
|
-
const tar = await runCommand("tar", ["-cf", temporaryTar, "-C", staging, "."]);
|
|
3520
|
+
const tar = await runCommand("tar", [output.endsWith(".gz") ? "-czf" : "-cf", temporaryTar, "-C", staging, "."]);
|
|
3383
3521
|
if (!tar.success)
|
|
3384
3522
|
throw new Error(tar.error.message);
|
|
3385
3523
|
chmodSync(temporaryTar, 384);
|
|
@@ -3418,7 +3556,7 @@ ${instructions}
|
|
|
3418
3556
|
};
|
|
3419
3557
|
writeFileSync(join(scripts, "deploy-target.json"), `${JSON.stringify({ ...target, _comments: comments }, null, 2)}
|
|
3420
3558
|
`);
|
|
3421
|
-
writeFileSync(join(scripts, "README.txt"),
|
|
3559
|
+
writeFileSync(join(scripts, "README.txt"), readme);
|
|
3422
3560
|
for (const file of [`docker-compose-${artifact.appVersion}.yml`, ".env.runtime", "middleware-runtime.env", "request-access.yml", ...options.hasWebConfig ? ["web.conf"] : []])
|
|
3423
3561
|
rmSync(join(directory, file), { force: true });
|
|
3424
3562
|
return { bundlePath: output, instructions: `${selection}
|
|
@@ -3429,6 +3567,178 @@ ${scripts}`, startName: "apply.sh" };
|
|
|
3429
3567
|
rmSync(temporaryTar, { force: true });
|
|
3430
3568
|
}
|
|
3431
3569
|
}
|
|
3570
|
+
async function copyArchive(source, destination) {
|
|
3571
|
+
const hash = createHash("sha256");
|
|
3572
|
+
const digest = new Transform({
|
|
3573
|
+
transform(chunk, _encoding, callback) {
|
|
3574
|
+
hash.update(chunk);
|
|
3575
|
+
callback(null, chunk);
|
|
3576
|
+
}
|
|
3577
|
+
});
|
|
3578
|
+
await pipeline(createReadStream(source), digest, createWriteStream(destination, { mode: 384 }));
|
|
3579
|
+
return hash.digest("hex");
|
|
3580
|
+
}
|
|
3581
|
+
var CERTBOT_IMAGE = "docker.io/certbot/certbot:v5.9.0";
|
|
3582
|
+
function createHttpsAssets(config, projectDirectory) {
|
|
3583
|
+
const assets = {
|
|
3584
|
+
https: `${config.domain} ${config.port} ${config.type}
|
|
3585
|
+
`
|
|
3586
|
+
};
|
|
3587
|
+
if (config.type === "files") {
|
|
3588
|
+
try {
|
|
3589
|
+
const readPem = (file) => {
|
|
3590
|
+
const path = resolve(projectDirectory, file);
|
|
3591
|
+
if (!statSync(path).isFile() || statSync(path).size > 1048576)
|
|
3592
|
+
throw new Error("Invalid PEM file");
|
|
3593
|
+
return readFileSync(path, "utf8");
|
|
3594
|
+
};
|
|
3595
|
+
const certificate = readPem(config.certificateFile);
|
|
3596
|
+
const key = readPem(config.privateKeyFile);
|
|
3597
|
+
const parsed = new X509Certificate(certificate);
|
|
3598
|
+
const publicKey = createPublicKey(createPrivateKey(key)).export({ type: "spki", format: "der" });
|
|
3599
|
+
const now = Date.now();
|
|
3600
|
+
if (!publicKey.equals(parsed.publicKey.export({ type: "spki", format: "der" })) || now < Date.parse(parsed.validFrom) || now >= Date.parse(parsed.validTo) || !(isIP(config.domain) ? parsed.checkIP(config.domain) : parsed.checkHost(config.domain))) {
|
|
3601
|
+
throw new Error("Certificate validation failed");
|
|
3602
|
+
}
|
|
3603
|
+
assets["tls/fullchain.pem"] = certificate;
|
|
3604
|
+
assets["tls/privkey.pem"] = key;
|
|
3605
|
+
} catch {
|
|
3606
|
+
throw new Error(deployM("deploy_httpsCertificateInvalid"));
|
|
3607
|
+
}
|
|
3608
|
+
} else {
|
|
3609
|
+
assets["renew-https.sh"] = createRenewScript(config);
|
|
3610
|
+
}
|
|
3611
|
+
assets["prepare-https.sh"] = createPrepareScript(config);
|
|
3612
|
+
assets["https-start.sh"] = String.raw`#!/bin/sh
|
|
3613
|
+
set -eu
|
|
3614
|
+
# 目录挂载允许原子替换证书;只有新证书通过 nginx -t 才重载。
|
|
3615
|
+
watch_certificate() {
|
|
3616
|
+
previous=$(sha256sum /etc/nginx/tls/fullchain.pem)
|
|
3617
|
+
while sleep 60; do
|
|
3618
|
+
current=$(sha256sum /etc/nginx/tls/fullchain.pem) || continue
|
|
3619
|
+
if [ "$previous" != "$current" ]; then
|
|
3620
|
+
if nginx -t && nginx -s reload; then previous=$current; else printf '[ERROR] HTTPS certificate reload failed\n' >&2; fi
|
|
3621
|
+
fi
|
|
3622
|
+
done
|
|
3623
|
+
}
|
|
3624
|
+
watch_certificate &
|
|
3625
|
+
exec /docker-entrypoint.sh nginx -g 'daemon off;'
|
|
3626
|
+
`;
|
|
3627
|
+
return assets;
|
|
3628
|
+
}
|
|
3629
|
+
function renderHttpsProxy(source, config, httpPort) {
|
|
3630
|
+
const publicPort = config.port === 443 ? "" : `:${config.port}`;
|
|
3631
|
+
const challenge = " location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; }\n";
|
|
3632
|
+
const tls = source.replace(`listen ${httpPort};`, `listen ${httpPort === 8443 ? 8444 : 8443} ssl;
|
|
3633
|
+
ssl_certificate /etc/nginx/tls/fullchain.pem;
|
|
3634
|
+
ssl_certificate_key /etc/nginx/tls/privkey.pem;`).replace("server_name _;", `server_name ${config.domain};`).replaceAll("proxy_set_header X-Forwarded-Proto $scheme;", "proxy_set_header X-Forwarded-Proto https;");
|
|
3635
|
+
const http = config.redirectHttp ? `server {
|
|
3636
|
+
listen ${httpPort};
|
|
3637
|
+
server_name ${config.domain};
|
|
3638
|
+
${challenge} location ~ ^/_hai/(web-health|gateway-health)$ { access_log off; return 200 'ok'; }
|
|
3639
|
+
location / { return 308 https://${config.domain}${publicPort}$request_uri; }
|
|
3640
|
+
}
|
|
3641
|
+
` : source.slice(source.indexOf("server {")).replace("server_name _;", `server_name ${config.domain};
|
|
3642
|
+
${challenge}`);
|
|
3643
|
+
return `${tls}
|
|
3644
|
+
${http}`;
|
|
3645
|
+
}
|
|
3646
|
+
var publishCertificate = String.raw`publish_certificate() {
|
|
3647
|
+
certificate="/etc/letsencrypt/live/$domain"
|
|
3648
|
+
test -s "$certificate/fullchain.pem" && test -s "$certificate/privkey.pem"
|
|
3649
|
+
cp "$certificate/fullchain.pem" /output/fullchain.pem.next
|
|
3650
|
+
cp "$certificate/privkey.pem" /output/privkey.pem.next
|
|
3651
|
+
chmod 444 /output/fullchain.pem.next /output/privkey.pem.next
|
|
3652
|
+
mv /output/privkey.pem.next /output/privkey.pem
|
|
3653
|
+
mv /output/fullchain.pem.next /output/fullchain.pem
|
|
3654
|
+
}
|
|
3655
|
+
`;
|
|
3656
|
+
function createRenewScript(config) {
|
|
3657
|
+
return `#!/bin/sh
|
|
3658
|
+
set -eu
|
|
3659
|
+
domain=${shellArgument(config.domain)}
|
|
3660
|
+
${publishCertificate}${String.raw`
|
|
3661
|
+
trap 'exit 0' TERM INT
|
|
3662
|
+
# Certbot 自己判断到期时间;同一官方客户端同时负责申请与续期,不实现 ACME 协议。
|
|
3663
|
+
while :; do
|
|
3664
|
+
if certbot renew --cert-name "$domain" --non-interactive; then
|
|
3665
|
+
if [ -s "/etc/letsencrypt/live/$domain/fullchain.pem" ]; then
|
|
3666
|
+
current=$(sha256sum "/etc/letsencrypt/live/$domain/fullchain.pem" | awk '{print $1}')
|
|
3667
|
+
installed=$(sha256sum /output/fullchain.pem | awk '{print $1}')
|
|
3668
|
+
if [ "$current" != "$installed" ]; then publish_certificate; fi
|
|
3669
|
+
fi
|
|
3670
|
+
else
|
|
3671
|
+
printf '[ERROR] Certbot renewal failed; the existing certificate remains installed\n' >&2
|
|
3672
|
+
fi
|
|
3673
|
+
sleep 43200 & wait $! || exit 0
|
|
3674
|
+
done
|
|
3675
|
+
`}`;
|
|
3676
|
+
}
|
|
3677
|
+
function createPrepareScript(config) {
|
|
3678
|
+
const header = `#!/bin/sh
|
|
3679
|
+
set -eu
|
|
3680
|
+
umask 077
|
|
3681
|
+
root=$1
|
|
3682
|
+
release=$2
|
|
3683
|
+
runtime=$3
|
|
3684
|
+
mode=$4
|
|
3685
|
+
domain=${shellArgument(config.domain)}
|
|
3686
|
+
`;
|
|
3687
|
+
if (config.type === "files") {
|
|
3688
|
+
return header + String.raw`
|
|
3689
|
+
test "$mode" != prepare || {
|
|
3690
|
+
# 宿主 root/release 保持私密;容器仅可读取独立挂载的证书目录。
|
|
3691
|
+
chmod 700 "$root" "$release"
|
|
3692
|
+
chmod 755 "$release/tls"
|
|
3693
|
+
chmod 444 "$release/tls/fullchain.pem" "$release/tls/privkey.pem"
|
|
3694
|
+
}
|
|
3695
|
+
`;
|
|
3696
|
+
}
|
|
3697
|
+
const python = [
|
|
3698
|
+
"from cryptography import x509",
|
|
3699
|
+
"from cryptography.x509.oid import NameOID",
|
|
3700
|
+
"from cryptography.hazmat.primitives import hashes, serialization",
|
|
3701
|
+
"from cryptography.hazmat.primitives.asymmetric import ec",
|
|
3702
|
+
"from datetime import datetime, timedelta, timezone",
|
|
3703
|
+
"from pathlib import Path",
|
|
3704
|
+
"import sys",
|
|
3705
|
+
"name=sys.argv[1]",
|
|
3706
|
+
"key=ec.generate_private_key(ec.SECP256R1())",
|
|
3707
|
+
"subject=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,name)])",
|
|
3708
|
+
"now=datetime.now(timezone.utc)",
|
|
3709
|
+
"cert=x509.CertificateBuilder().subject_name(subject).issuer_name(subject).public_key(key.public_key()).serial_number(x509.random_serial_number()).not_valid_before(now-timedelta(minutes=1)).not_valid_after(now+timedelta(days=1)).add_extension(x509.SubjectAlternativeName([x509.DNSName(name)]),False).sign(key,hashes.SHA256())",
|
|
3710
|
+
'Path("/output/privkey.pem").write_bytes(key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()))',
|
|
3711
|
+
'Path("/output/fullchain.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM))'
|
|
3712
|
+
].join("\n");
|
|
3713
|
+
const publish = `set -eu
|
|
3714
|
+
domain=${shellArgument(config.domain)}
|
|
3715
|
+
${publishCertificate}
|
|
3716
|
+
publish_certificate
|
|
3717
|
+
`;
|
|
3718
|
+
return `${header}image=${shellArgument(CERTBOT_IMAGE)}
|
|
3719
|
+
email=${shellArgument(config.email)}
|
|
3720
|
+
bootstrap=${shellArgument(python)}
|
|
3721
|
+
${String.raw`
|
|
3722
|
+
state="$root/https"
|
|
3723
|
+
certificates="$state/certificates/$domain"
|
|
3724
|
+
mkdir -p "$state/acme" "$state/webroot" "$certificates"
|
|
3725
|
+
chmod 700 "$root" "$state" "$state/acme"
|
|
3726
|
+
chmod 755 "$state/webroot" "$certificates"
|
|
3727
|
+
if [ "$mode" = prepare ]; then
|
|
3728
|
+
if [ ! -s "$certificates/fullchain.pem" ] || [ ! -s "$certificates/privkey.pem" ]; then
|
|
3729
|
+
if [ "$5" = true ]; then printf 'HTTPS certificate state missing; create a full deployment.\n' >&2; exit 1; fi
|
|
3730
|
+
# 临时证书只用于启动挑战入口;签发失败不得提交 current。
|
|
3731
|
+
"$runtime" run --rm --pull never --entrypoint python -v "$certificates:/output" "$image" -c "$bootstrap" "$domain"
|
|
3732
|
+
chmod 444 "$certificates/fullchain.pem" "$certificates/privkey.pem"
|
|
3733
|
+
fi
|
|
3734
|
+
elif [ "$mode" = issue ]; then
|
|
3735
|
+
"$runtime" run --rm --pull never -v "$state/acme:/etc/letsencrypt" -v "$state/webroot:/var/www/certbot" "$image" certonly --webroot --webroot-path /var/www/certbot --cert-name "$domain" --domain "$domain" --email "$email" --agree-tos --non-interactive --keep-until-expiring
|
|
3736
|
+
"$runtime" run --rm --pull never --entrypoint sh -v "$state/acme:/etc/letsencrypt:ro" -v "$certificates:/output" "$image" -c `}${shellArgument(publish)}
|
|
3737
|
+
fi
|
|
3738
|
+
`;
|
|
3739
|
+
}
|
|
3740
|
+
|
|
3741
|
+
// src/container/deploy-container-compose.ts
|
|
3432
3742
|
var IMAGES = {
|
|
3433
3743
|
postgres: "docker.io/library/postgres:16.15-alpine",
|
|
3434
3744
|
mysql: "docker.io/library/mysql:8.4.11",
|
|
@@ -3693,12 +4003,17 @@ function planSidecars(_appDir, projectName, scan, provisionEnv, options, s3Engin
|
|
|
3693
4003
|
const requirements = scan.middlewares?.filter((item) => item.service === "email" && item.backend === "smtp" && ["default", void 0].includes(options?.serviceModes?.[item.envPrefix] ?? options?.serviceModes?.email));
|
|
3694
4004
|
if (requirements?.length) {
|
|
3695
4005
|
for (const requirement of requirements)
|
|
3696
|
-
plan.env[requirement.envPrefix] = JSON.stringify({ name: requirement.config.name ?? "email", type: "smtp", host: "email", port: 1025, secure: false, from: "noreply@example.com" });
|
|
4006
|
+
plan.env[requirement.envPrefix] = JSON.stringify({ name: requirement.config.name ?? "email", type: "smtp", host: "email", port: 1025, secure: false, from: requirement.config.from || "noreply@example.com" });
|
|
3697
4007
|
} else if (!scan.middlewares) {
|
|
3698
4008
|
plan.env.HAI_REACH_PROVIDERS_0 = JSON.stringify({ name: "email", type: "smtp", host: "email", port: 1025, secure: false, from: "noreply@example.com" });
|
|
3699
4009
|
}
|
|
3700
4010
|
}
|
|
3701
4011
|
}
|
|
4012
|
+
for (const requirement of scan.middlewares ?? []) {
|
|
4013
|
+
const supplied = plan.env[requirement.envPrefix];
|
|
4014
|
+
if (supplied)
|
|
4015
|
+
plan.env[requirement.envPrefix] = JSON.stringify(mergeManagedMiddlewareConfig(requirement, JSON.parse(supplied)));
|
|
4016
|
+
}
|
|
3702
4017
|
return plan;
|
|
3703
4018
|
}
|
|
3704
4019
|
function buildRuntimeEnv(appDir, projectName, scan, provisionEnv, containerPort, options, appEnv = {}, s3Engine = "rustfs") {
|
|
@@ -3717,6 +4032,7 @@ function buildRuntimeEnv(appDir, projectName, scan, provisionEnv, containerPort,
|
|
|
3717
4032
|
function generateComposeFile(options) {
|
|
3718
4033
|
const { projectName, imageRef, hostPort, containerPort, sidecar } = options;
|
|
3719
4034
|
const primaryService = options.primaryService ?? "serv";
|
|
4035
|
+
const nodeRuntime = options.nodeRuntime ?? primaryService === "serv";
|
|
3720
4036
|
const app = {
|
|
3721
4037
|
image: imageRef,
|
|
3722
4038
|
labels: { "org.opencontainers.image.version": options.appVersion },
|
|
@@ -3728,11 +4044,28 @@ function generateComposeFile(options) {
|
|
|
3728
4044
|
ports: [`${hostPort}:${containerPort}`],
|
|
3729
4045
|
networks: ["hai-app"]
|
|
3730
4046
|
};
|
|
4047
|
+
if (primaryService === "serv") {
|
|
4048
|
+
app.healthcheck = {
|
|
4049
|
+
test: ["CMD", "node", "-e", "(async () => { try { const { core } = await import('@h-ai/core'); const { ServConfigSchema } = await import('@h-ai/serv'); core.init({ configDir: './config' }); const { server, http } = ServConfigSchema.parse(core.config.getOrThrow('serv')); if (http.health === false) process.exit(0); const response = await fetch('http://127.0.0.1:' + server.port + http.health.path, { signal: AbortSignal.timeout(4000) }); process.exit(response.ok ? 0 : 1); } catch { process.exit(1); } })()"],
|
|
4050
|
+
interval: "30s",
|
|
4051
|
+
timeout: "5s",
|
|
4052
|
+
start_period: "15s",
|
|
4053
|
+
retries: 3
|
|
4054
|
+
};
|
|
4055
|
+
}
|
|
3731
4056
|
const appVolumes = [...sidecar.appVolumes];
|
|
4057
|
+
const appEnvironment = {};
|
|
4058
|
+
if (nodeRuntime) {
|
|
4059
|
+
delete app.env_file;
|
|
4060
|
+
appVolumes.push("./.env.runtime:/run/hai/.env.runtime:ro", "./middleware.env:/run/hai/middleware.env:ro", "./runtime-env.mjs:/run/hai/runtime-env.mjs:ro");
|
|
4061
|
+
appEnvironment.NODE_OPTIONS = `--import /run/hai/runtime-env.mjs${options.nodeOptions ? ` ${options.nodeOptions}` : ""}`.replaceAll("$", () => "$$");
|
|
4062
|
+
}
|
|
3732
4063
|
if (options.requestAccessPolicy)
|
|
3733
4064
|
appVolumes.push("./request-access.yml:/run/hai/request-access.yml:ro");
|
|
3734
4065
|
if (options.web || options.gateway)
|
|
3735
|
-
|
|
4066
|
+
appEnvironment.HAI_DEPLOY_PRIVATE_PROXY = "1";
|
|
4067
|
+
if (Object.keys(appEnvironment).length)
|
|
4068
|
+
app.environment = appEnvironment;
|
|
3736
4069
|
if (options.mountWebConfig)
|
|
3737
4070
|
appVolumes.push("./web.conf:/etc/nginx/conf.d/default.conf:ro");
|
|
3738
4071
|
if (appVolumes.length)
|
|
@@ -3765,10 +4098,38 @@ function generateComposeFile(options) {
|
|
|
3765
4098
|
ports: [`${hostPort}:8080`],
|
|
3766
4099
|
healthcheck: { test: ["CMD", "wget", "-Y", "off", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/_hai/gateway-health"], interval: "30s", timeout: "5s", retries: 3 },
|
|
3767
4100
|
volumes: ["./web.conf:/etc/nginx/conf.d/default.conf:ro"],
|
|
3768
|
-
depends_on: [primaryService, "storage"],
|
|
4101
|
+
depends_on: [primaryService, ...sidecar.services.includes("storage") ? ["storage"] : []],
|
|
3769
4102
|
networks: ["hai-app"]
|
|
3770
4103
|
};
|
|
3771
4104
|
}
|
|
4105
|
+
if (options.https) {
|
|
4106
|
+
const proxy = services[options.gateway ? "gateway" : options.web ? "web" : primaryService];
|
|
4107
|
+
const https = options.https;
|
|
4108
|
+
const httpPort = options.gateway ? 8080 : options.web?.containerPort ?? containerPort;
|
|
4109
|
+
proxy.ports = [...proxy.ports, `${https.port}:${httpPort === 8443 ? 8444 : 8443}`];
|
|
4110
|
+
const certificates = https.type === "files" ? "./tls" : `../../https/certificates/${https.domain}`;
|
|
4111
|
+
proxy.volumes = [
|
|
4112
|
+
...proxy.volumes,
|
|
4113
|
+
`${certificates}:/etc/nginx/tls:ro`,
|
|
4114
|
+
"./https-start.sh:/run/hai/https-start.sh:ro",
|
|
4115
|
+
...https.type === "letsencrypt" ? ["../../https/webroot:/var/www/certbot:ro"] : []
|
|
4116
|
+
];
|
|
4117
|
+
proxy.entrypoint = ["sh", "/run/hai/https-start.sh"];
|
|
4118
|
+
if (https.type === "letsencrypt") {
|
|
4119
|
+
services.certbot = {
|
|
4120
|
+
image: CERTBOT_IMAGE,
|
|
4121
|
+
restart: "unless-stopped",
|
|
4122
|
+
entrypoint: ["sh", "/run/hai/renew-https.sh"],
|
|
4123
|
+
volumes: [
|
|
4124
|
+
"../../https/acme:/etc/letsencrypt",
|
|
4125
|
+
"../../https/webroot:/var/www/certbot",
|
|
4126
|
+
`../../https/certificates/${https.domain}:/output`,
|
|
4127
|
+
"./renew-https.sh:/run/hai/renew-https.sh:ro"
|
|
4128
|
+
],
|
|
4129
|
+
networks: ["hai-app"]
|
|
4130
|
+
};
|
|
4131
|
+
}
|
|
4132
|
+
}
|
|
3772
4133
|
const compose = {
|
|
3773
4134
|
"x-hai-version": options.appVersion,
|
|
3774
4135
|
services,
|
|
@@ -3787,11 +4148,21 @@ function generateWebProxyConfig(port, apiPrefix, options = {}) {
|
|
|
3787
4148
|
const webPort = options.webPort ?? 8080;
|
|
3788
4149
|
const webBasePath = options.webBasePath ?? "";
|
|
3789
4150
|
const apiPath = apiPrefix.replace(/\/+$/, "");
|
|
3790
|
-
|
|
4151
|
+
const backendPaths = [...new Set(options.backendPaths ?? ["/health"])];
|
|
4152
|
+
if (!/^\/[\w/-]+$/.test(apiPrefix) || ![port, webPort].every((value) => Number.isInteger(value) && value >= 1 && value <= 65535) || backendPaths.some((path) => !/^\/[\w./-]*$/.test(path) || path.startsWith("//") || path.split("/").some((part) => part === "." || part === "..") || path === `${webBasePath}/runtime-config.js` || path === "/_hai/web-health") || webBasePath && (backendPaths.includes(webBasePath) || webBasePath === apiPath || webBasePath.startsWith(`${apiPath}/`))) {
|
|
3791
4153
|
throw new Error("Invalid web proxy target");
|
|
3792
4154
|
}
|
|
3793
4155
|
const webLocation = renderWebLocation(webBasePath);
|
|
3794
|
-
const storageLocation = options.storageBucket ? renderStorageLocation(options.storageBucket, [apiPath, ...options.storageBasePath ? [`${webBasePath}/_app`, `${webBasePath}/assets`, `${webBasePath}/runtime-config.js`] : [webBasePath]], options.storageBasePath) : "";
|
|
4156
|
+
const storageLocation = options.storageBucket ? renderStorageLocation(options.storageBucket, [apiPath, ...backendPaths, ...options.storageBasePath ? [`${webBasePath}/_app`, `${webBasePath}/assets`, `${webBasePath}/runtime-config.js`] : [webBasePath]], options.storageBasePath) : "";
|
|
4157
|
+
const backendLocations = backendPaths.map((path) => ` location = ${path} {
|
|
4158
|
+
proxy_pass http://serv:${port};
|
|
4159
|
+
proxy_http_version 1.1;
|
|
4160
|
+
proxy_set_header Host $http_host;
|
|
4161
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
4162
|
+
proxy_set_header X-Forwarded-For $remote_addr;
|
|
4163
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
4164
|
+
}
|
|
4165
|
+
`).join("");
|
|
3795
4166
|
return `${options.storageBucket ? renderStorageUpstream(options.storageBucket, options.storageBasePath) : ""}server {
|
|
3796
4167
|
listen ${webPort};
|
|
3797
4168
|
server_name _;
|
|
@@ -3806,13 +4177,7 @@ ${storageLocation}${webLocation} location = ${webBasePath}/runtime-config.js {
|
|
|
3806
4177
|
access_log off;
|
|
3807
4178
|
return 200 'ok';
|
|
3808
4179
|
}
|
|
3809
|
-
location
|
|
3810
|
-
proxy_pass http://serv:${port};
|
|
3811
|
-
proxy_set_header Host $http_host;
|
|
3812
|
-
proxy_set_header X-Forwarded-For $remote_addr;
|
|
3813
|
-
proxy_set_header X-Real-IP $remote_addr;
|
|
3814
|
-
}
|
|
3815
|
-
location ${apiPrefix.replace(/\/$/, "")}/ {
|
|
4180
|
+
${backendLocations} location ${apiPrefix.replace(/\/$/, "")}/ {
|
|
3816
4181
|
client_max_body_size ${bodyLimits.encryptedPayloadBytes};
|
|
3817
4182
|
proxy_pass http://serv:${port};
|
|
3818
4183
|
proxy_http_version 1.1;
|
|
@@ -3878,11 +4243,11 @@ function generateApplicationProxyConfig(options) {
|
|
|
3878
4243
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
3879
4244
|
throw new Error("Invalid application proxy target");
|
|
3880
4245
|
const bodyLimits = resolveTransportBodyLimits(maxPlaintextBytes);
|
|
3881
|
-
return `${renderStorageUpstream(bucket, options.storageBasePath)}server {
|
|
4246
|
+
return `${bucket ? renderStorageUpstream(bucket, options.storageBasePath) : ""}server {
|
|
3882
4247
|
listen 8080;
|
|
3883
4248
|
server_name _;
|
|
3884
4249
|
${renderTrustedEdgeProxies(options.trustedEdgeProxies)} large_client_header_buffers 4 32k;
|
|
3885
|
-
${renderStorageLocation(bucket, reservedPaths, options.storageBasePath)} location = /_hai/gateway-health {
|
|
4250
|
+
${bucket ? renderStorageLocation(bucket, reservedPaths, options.storageBasePath) : ""} location = /_hai/gateway-health {
|
|
3886
4251
|
access_log off;
|
|
3887
4252
|
return 200 'ok';
|
|
3888
4253
|
}
|
|
@@ -3990,9 +4355,22 @@ function prepareRequestAccess(options) {
|
|
|
3990
4355
|
}
|
|
3991
4356
|
}
|
|
3992
4357
|
var HEALTH_MAX_ATTEMPTS = 40;
|
|
4358
|
+
function servicePath(value, fallback) {
|
|
4359
|
+
const path = value ?? fallback;
|
|
4360
|
+
if (typeof path !== "string" || !/^\/[\w./-]*$/.test(path) || path.startsWith("//") || path.split("/").some((part) => part === "." || part === ".."))
|
|
4361
|
+
throw new Error("Invalid service endpoint path");
|
|
4362
|
+
return path;
|
|
4363
|
+
}
|
|
4364
|
+
function endpointSettings(value) {
|
|
4365
|
+
if (value === void 0)
|
|
4366
|
+
return {};
|
|
4367
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
4368
|
+
throw new Error("Invalid service endpoint configuration");
|
|
4369
|
+
return value;
|
|
4370
|
+
}
|
|
3993
4371
|
var ServerResultSchema = z.object({
|
|
3994
4372
|
deploymentId: z.string(),
|
|
3995
|
-
stage: z.enum(["preflight", "credentials", "recover", "images", "dependencies", "start", "health", "complete"]),
|
|
4373
|
+
stage: z.enum(["preflight", "credentials", "recover", "images", "dependencies", "start", "https", "health", "complete"]),
|
|
3996
4374
|
exitCode: z.number().int().min(0).max(255),
|
|
3997
4375
|
durationSeconds: z.number().int().nonnegative(),
|
|
3998
4376
|
recoveryStatus: z.enum(["not-needed", "passed", "failed", "stopped"]).optional()
|
|
@@ -4004,7 +4382,7 @@ function makeDeploymentId() {
|
|
|
4004
4382
|
return `${stamp}-${randomBytes(3).toString("hex")}`;
|
|
4005
4383
|
}
|
|
4006
4384
|
function sleep(ms) {
|
|
4007
|
-
return new Promise((
|
|
4385
|
+
return new Promise((resolve9) => setTimeout(resolve9, ms));
|
|
4008
4386
|
}
|
|
4009
4387
|
function createDockerSshProvider(config, container) {
|
|
4010
4388
|
const ssh = config.ssh ? createSshExecutor(config.ssh) : void 0;
|
|
@@ -4028,13 +4406,28 @@ function createDockerSshProvider(config, container) {
|
|
|
4028
4406
|
deployM("deploy_remoteRuntimeMissing", { params: { runtime: candidates.join(" / ") } })
|
|
4029
4407
|
);
|
|
4030
4408
|
}
|
|
4031
|
-
async function healthCheck(url) {
|
|
4409
|
+
async function healthCheck(url, certificate) {
|
|
4032
4410
|
for (let attempt = 0; attempt < HEALTH_MAX_ATTEMPTS; attempt++) {
|
|
4033
4411
|
try {
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4412
|
+
if (certificate) {
|
|
4413
|
+
const healthy = await new Promise((resolve9) => {
|
|
4414
|
+
const request$1 = request(url, { ca: [...rootCertificates, certificate], timeout: 5e3 }, (response) => {
|
|
4415
|
+
response.resume();
|
|
4416
|
+
resolve9(response.statusCode !== void 0 && response.statusCode >= 200 && response.statusCode < 300);
|
|
4417
|
+
});
|
|
4418
|
+
request$1.on("error", () => resolve9(false));
|
|
4419
|
+
request$1.on("close", () => resolve9(false));
|
|
4420
|
+
request$1.on("timeout", () => request$1.destroy());
|
|
4421
|
+
request$1.end();
|
|
4422
|
+
});
|
|
4423
|
+
if (healthy)
|
|
4424
|
+
return true;
|
|
4425
|
+
} else {
|
|
4426
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(5e3) });
|
|
4427
|
+
await response.body?.cancel();
|
|
4428
|
+
if (response.ok)
|
|
4429
|
+
return true;
|
|
4430
|
+
}
|
|
4038
4431
|
} catch {
|
|
4039
4432
|
}
|
|
4040
4433
|
await sleep(HEALTH_POLL_INTERVAL);
|
|
@@ -4045,6 +4438,8 @@ function createDockerSshProvider(config, container) {
|
|
|
4045
4438
|
const { appDir, projectName, scan, provisionEnv, appEnv, options } = context;
|
|
4046
4439
|
const deploymentId = makeDeploymentId();
|
|
4047
4440
|
const hostPort = config.expose.hostPort;
|
|
4441
|
+
const https = config.expose.https;
|
|
4442
|
+
const httpsAssets = https ? createHttpsAssets(https, resolveProjectDirectory(appDir)) : void 0;
|
|
4048
4443
|
const deploymentRoot = join(resolveProjectDirectory(appDir), ".hai", "deploy", options?.environment ?? "prod");
|
|
4049
4444
|
const artifact = context.artifact;
|
|
4050
4445
|
const resolvedBuildDir = context.artifactDir;
|
|
@@ -4063,12 +4458,14 @@ function createDockerSshProvider(config, container) {
|
|
|
4063
4458
|
const containerPort = app?.containerPort ?? (primaryService === "serv" ? artifact.servContainerPort : artifact.webContainerPort) ?? config.expose.containerPort;
|
|
4064
4459
|
const webContainerPort = web?.containerPort ?? artifact.webContainerPort ?? 8080;
|
|
4065
4460
|
const host = config.ssh?.host;
|
|
4066
|
-
const url = host ? `http://${host.includes(":") ? `[${host}]` : host}:${hostPort}` : `http://SERVER_HOST:${hostPort}`;
|
|
4461
|
+
const url = https ? `https://${https.domain}${https.port === 443 ? "" : `:${https.port}`}` : host ? `http://${host.includes(":") ? `[${host}]` : host}:${hostPort}` : `http://SERVER_HOST:${hostPort}`;
|
|
4067
4462
|
const webBasePath = artifact.webBasePath ?? "";
|
|
4068
4463
|
const applicationUrl = `${url}${webBasePath ? `${webBasePath}/` : ""}`;
|
|
4069
|
-
const standaloneWebConfig = primaryService === "web" && !scan.isSvelteKit && !!webBasePath;
|
|
4070
|
-
const healthPaths = hasWebProxy ? ["/
|
|
4464
|
+
const standaloneWebConfig = primaryService === "web" && !scan.isSvelteKit && (!!webBasePath || !!https);
|
|
4465
|
+
const healthPaths = hasWebProxy ? ["/_hai/web-health", `${webBasePath}/`] : scan.isSvelteKit ? [`${webBasePath}/`] : primaryService === "serv" ? [] : standaloneWebConfig ? ["/health", `${webBasePath}/`] : ["/health"];
|
|
4071
4466
|
const { env, sidecar } = buildRuntimeEnv(appDir, projectName, scan, provisionEnv, containerPort, options, appEnv, container?.s3Engine);
|
|
4467
|
+
if (https && scan.isSvelteKit && !env.ORIGIN)
|
|
4468
|
+
env.ORIGIN = url;
|
|
4072
4469
|
const managedStorage = sidecar.services.includes("storage") ? JSON.parse(sidecar.env.HAI_STORAGE) : void 0;
|
|
4073
4470
|
let storageBasePath = "";
|
|
4074
4471
|
if (managedStorage?.publicEndpoint !== void 0) {
|
|
@@ -4086,7 +4483,7 @@ function createDockerSshProvider(config, container) {
|
|
|
4086
4483
|
}
|
|
4087
4484
|
}
|
|
4088
4485
|
const storageRouting = managedStorage ? createHash("sha256").update(JSON.stringify({ ...managedStorage, secretAccessKey: void 0 })).digest("hex") : "";
|
|
4089
|
-
const gateway = !!managedStorage && !
|
|
4486
|
+
const gateway = !hasWebProxy && (!!managedStorage || !!https && !standaloneWebConfig);
|
|
4090
4487
|
if (gateway && primaryService === "web" && !scan.isSvelteKit)
|
|
4091
4488
|
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_storageBackendRequired"));
|
|
4092
4489
|
const proxyService = gateway ? "gateway" : "web";
|
|
@@ -4097,6 +4494,35 @@ function createDockerSshProvider(config, container) {
|
|
|
4097
4494
|
const server = existsSync(serverPath) || hasWebProxy ? loadDeploymentConfig("serv", serverPath, env) : ok({});
|
|
4098
4495
|
if (!server.success)
|
|
4099
4496
|
return server;
|
|
4497
|
+
const backendPaths = [];
|
|
4498
|
+
try {
|
|
4499
|
+
const http = endpointSettings(server.data.http);
|
|
4500
|
+
if (primaryService === "serv" && http.health !== false) {
|
|
4501
|
+
const health = endpointSettings(http.health);
|
|
4502
|
+
const path = servicePath(health.path, "/health");
|
|
4503
|
+
healthPaths.unshift(path);
|
|
4504
|
+
backendPaths.push(path, servicePath(health.readyPath, "/ready"));
|
|
4505
|
+
}
|
|
4506
|
+
if (hasWebProxy) {
|
|
4507
|
+
const corePath = join(appDir, "config", "_core.yml");
|
|
4508
|
+
const settings = existsSync(corePath) ? loadDeploymentConfig("core", corePath, env) : ok(env.HAI_CORE ? JSON.parse(env.HAI_CORE) : { env: env.HAI_CORE_ENV });
|
|
4509
|
+
if (!settings.success)
|
|
4510
|
+
return settings;
|
|
4511
|
+
const application = CoreConfigSchema.safeParse(settings.data);
|
|
4512
|
+
if (!application.success)
|
|
4513
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_webProxyInvalid"));
|
|
4514
|
+
if (application.data.env !== "production") {
|
|
4515
|
+
if (http.openapi !== void 0 && http.openapi !== false)
|
|
4516
|
+
backendPaths.push(servicePath(endpointSettings(http.openapi).path, "/openapi.json"));
|
|
4517
|
+
if (http.docs !== void 0 && http.docs !== false) {
|
|
4518
|
+
const docsPath = servicePath(endpointSettings(http.docs).path, "/docs");
|
|
4519
|
+
backendPaths.push(docsPath, `${docsPath.slice(0, docsPath.lastIndexOf("/") + 1)}_hai/scalar.js`);
|
|
4520
|
+
}
|
|
4521
|
+
}
|
|
4522
|
+
}
|
|
4523
|
+
} catch {
|
|
4524
|
+
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_webProxyInvalid"));
|
|
4525
|
+
}
|
|
4100
4526
|
let requestAccessSnapshot;
|
|
4101
4527
|
if (updatesApp && primaryService === "serv") {
|
|
4102
4528
|
const prepared2 = prepareRequestAccess({ settings: server.data, module: "serv", directory: appDir });
|
|
@@ -4158,25 +4584,25 @@ function createDockerSshProvider(config, container) {
|
|
|
4158
4584
|
if (typeof apiPrefix !== "string" || !/^\/[\w/-]+$/.test(apiPrefix)) {
|
|
4159
4585
|
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_webProxyInvalid"));
|
|
4160
4586
|
}
|
|
4161
|
-
if (webBasePath && ([
|
|
4587
|
+
if (webBasePath && ([...backendPaths, "/_hai/web-health"].includes(webBasePath) || webBasePath === apiPrefix.replace(/\/+$/, "") || webBasePath.startsWith(`${apiPrefix.replace(/\/+$/, "")}/`)))
|
|
4162
4588
|
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_webBaseConflict"));
|
|
4163
4589
|
const transport = server.data.transport;
|
|
4164
4590
|
try {
|
|
4165
4591
|
const configured = transport ? transport.maxPlaintextBytes : void 0;
|
|
4166
4592
|
const limits = resolveTransportBodyLimits(Math.max(createTransportBodyPolicy(configured === "auto" ? void 0 : configured, transport ? transport.routePlaintextBytes : void 0).maximum.plaintextBytes, feedbackTransportBytes ?? 0));
|
|
4167
|
-
webProxy = generateWebProxyConfig(containerPort, apiPrefix, { webPort: webContainerPort, maxPlaintextBytes: limits.plaintextBytes, webBasePath, storageBucket: managedStorage?.bucket, storageBasePath, trustedEdgeProxies: config.trustedEdgeProxies });
|
|
4593
|
+
webProxy = generateWebProxyConfig(containerPort, apiPrefix, { webPort: webContainerPort, maxPlaintextBytes: limits.plaintextBytes, webBasePath, backendPaths, storageBucket: managedStorage?.bucket, storageBasePath, trustedEdgeProxies: config.trustedEdgeProxies });
|
|
4168
4594
|
} catch (error) {
|
|
4169
4595
|
return err(HaiDeployError.CONFIG_ERROR, error instanceof RangeError ? error.message : deployM(managedStorage ? "deploy_storageProxyInvalid" : "deploy_transportBodyLimitInvalid"));
|
|
4170
4596
|
}
|
|
4171
|
-
env.HAI_SERV = JSON.stringify({ ...server.data, cors: { ...cors, origin: (!cors?.origin || cors.origin === "*") && config.ssh ? url : cors?.origin ?? "*" } });
|
|
4172
|
-
} else if (gateway
|
|
4597
|
+
env.HAI_SERV = JSON.stringify({ ...server.data, cors: { ...cors, origin: (!cors?.origin || cors.origin === "*") && (config.ssh || https) ? url : cors?.origin ?? "*" } });
|
|
4598
|
+
} else if (gateway) {
|
|
4173
4599
|
try {
|
|
4174
4600
|
const http = server.data.http;
|
|
4175
4601
|
if (primaryService === "serv") {
|
|
4176
4602
|
const transport = server.data.transport;
|
|
4177
4603
|
applicationPlaintextBytes = Math.max(createTransportBodyPolicy(transport?.maxPlaintextBytes === "auto" ? void 0 : transport?.maxPlaintextBytes, transport?.routePlaintextBytes).maximum.plaintextBytes, feedbackTransportBytes ?? 0);
|
|
4178
4604
|
}
|
|
4179
|
-
webProxy = generateApplicationProxyConfig({ primaryService, port: containerPort, bucket: managedStorage
|
|
4605
|
+
webProxy = generateApplicationProxyConfig({ primaryService, port: containerPort, bucket: managedStorage?.bucket, storageBasePath, reservedPaths: [http?.apiPrefix ?? "/api/v1", ...storageBasePath ? [`${webBasePath}/_app`, `${webBasePath}/assets`, `${webBasePath}/runtime-config.js`] : [webBasePath]], maxPlaintextBytes: applicationPlaintextBytes, trustedEdgeProxies: config.trustedEdgeProxies });
|
|
4180
4606
|
} catch {
|
|
4181
4607
|
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_storageProxyInvalid"));
|
|
4182
4608
|
}
|
|
@@ -4185,8 +4611,15 @@ function createDockerSshProvider(config, container) {
|
|
|
4185
4611
|
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_webBaseConflict"));
|
|
4186
4612
|
webProxy = generateStaticWebConfig(containerPort, webBasePath);
|
|
4187
4613
|
}
|
|
4188
|
-
|
|
4189
|
-
|
|
4614
|
+
if (https && webProxy)
|
|
4615
|
+
webProxy = renderHttpsProxy(webProxy, https, hasWebProxy ? webContainerPort : gateway ? 8080 : containerPort);
|
|
4616
|
+
if (https && primaryService === "serv" && !hasWebProxy) {
|
|
4617
|
+
const cors = server.data.cors;
|
|
4618
|
+
env.HAI_SERV = JSON.stringify({ ...server.data, cors: { ...cors, origin: !cors?.origin || cors.origin === "*" ? url : cors.origin } });
|
|
4619
|
+
}
|
|
4620
|
+
const requestedOutput = options?.output ?? join(deploymentRoot, `bundle-${projectName}-${deploymentId}-${artifact.appVersion}.tar.gz`);
|
|
4621
|
+
const compressed = requestedOutput.endsWith(".tar.gz");
|
|
4622
|
+
const stem = basename(requestedOutput).replace(/\.tar(?:\.gz)?$/i, "");
|
|
4190
4623
|
const suffix = `-${artifact.appVersion}`;
|
|
4191
4624
|
const name = stem.endsWith(suffix) ? stem : `${stem}${suffix}`;
|
|
4192
4625
|
if (!/^[\w.+-]+$/.test(name))
|
|
@@ -4219,10 +4652,13 @@ function createDockerSshProvider(config, container) {
|
|
|
4219
4652
|
containerPort,
|
|
4220
4653
|
primaryService,
|
|
4221
4654
|
requestAccessPolicy: requestAccessSnapshot !== void 0,
|
|
4655
|
+
nodeRuntime: primaryService === "serv" || scan.isSvelteKit,
|
|
4656
|
+
nodeOptions: env.NODE_OPTIONS,
|
|
4222
4657
|
sidecar,
|
|
4223
4658
|
web: hasWebProxy ? { imageRef: web?.imageRef ?? "localhost/hai-unselected:0", containerPort: webContainerPort } : void 0,
|
|
4224
4659
|
mountWebConfig: standaloneWebConfig,
|
|
4225
|
-
gateway
|
|
4660
|
+
gateway,
|
|
4661
|
+
https
|
|
4226
4662
|
});
|
|
4227
4663
|
const composePath = join(artifactDir, composeName);
|
|
4228
4664
|
writeFileSync(composePath, composeContent);
|
|
@@ -4231,6 +4667,8 @@ function createDockerSshProvider(config, container) {
|
|
|
4231
4667
|
const selected = options?.selectedServices ? [...options.selectedServices] : artifact.partial ? images.map((image) => image.name) : [...images.map((image) => image.name), ...Object.keys(sidecar.composeServices)];
|
|
4232
4668
|
if (gateway && !partial)
|
|
4233
4669
|
selected.push("gateway");
|
|
4670
|
+
if (https?.type === "letsencrypt" && !partial)
|
|
4671
|
+
selected.push("certbot");
|
|
4234
4672
|
if (!selected.length || selected.some((name2) => !/^[a-z][a-z0-9-]*$/.test(name2)) || new Set(selected).size !== selected.length)
|
|
4235
4673
|
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_selectionInvalid", { params: { service: selected.join(", ") } }));
|
|
4236
4674
|
for (const name2 of selected) {
|
|
@@ -4255,7 +4693,9 @@ function createDockerSshProvider(config, container) {
|
|
|
4255
4693
|
containerPort,
|
|
4256
4694
|
middlewareEnv: serializeEnvFile(sidecar.env),
|
|
4257
4695
|
healthPaths,
|
|
4258
|
-
|
|
4696
|
+
httpsAssets,
|
|
4697
|
+
output: join(artifactDir, `${name}.tar${compressed ? ".gz" : ""}`),
|
|
4698
|
+
cacheDirectory: join(resolveProjectDirectory(appDir), ".hai", "cache", "images")
|
|
4259
4699
|
});
|
|
4260
4700
|
if (options?.bundleOnly)
|
|
4261
4701
|
return ok({ url: "", deploymentId, status: "packaged", provider: "docker-ssh", healthCheck: "skipped", envVarsSet: Object.keys(env), ...bundle });
|
|
@@ -4277,7 +4717,7 @@ function createDockerSshProvider(config, container) {
|
|
|
4277
4717
|
return uploaded;
|
|
4278
4718
|
let pendingOutput = "";
|
|
4279
4719
|
const started = await ssh.exec(
|
|
4280
|
-
`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)}`,
|
|
4720
|
+
`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)} && { rm -rf -- ${shellArgument(`${remoteTar}.contents`)} && rm -f -- ${shellArgument(remoteTar)} || printf '[WARN] Deployment passed; incoming archive cleanup failed\\n' >&2; }`,
|
|
4281
4721
|
{ timeoutMs: 6e5, secrets: sidecar.secrets, onOutput: (output) => {
|
|
4282
4722
|
const parts = output.split("\n");
|
|
4283
4723
|
for (let index = 0; index < parts.length; index++) {
|
|
@@ -4286,7 +4726,7 @@ function createDockerSshProvider(config, container) {
|
|
|
4286
4726
|
break;
|
|
4287
4727
|
const line = pendingOutput;
|
|
4288
4728
|
pendingOutput = "";
|
|
4289
|
-
const stage = /^\[(preflight|images|dependencies|start|health|complete)\]/.exec(line)?.[1];
|
|
4729
|
+
const stage = /^\[(preflight|images|dependencies|start|https|health|complete)\]/.exec(line)?.[1];
|
|
4290
4730
|
if (stage)
|
|
4291
4731
|
options?.onProgress?.(stage);
|
|
4292
4732
|
}
|
|
@@ -4319,7 +4759,7 @@ ${terminal ? deployM("deploy_remoteFinished", { params: { stage: terminal.stage,
|
|
|
4319
4759
|
logger5.info("Running health check", { url: applicationUrl });
|
|
4320
4760
|
let healthy = true;
|
|
4321
4761
|
for (const healthPath of healthPaths) {
|
|
4322
|
-
if (!await healthCheck(`${url}${healthPath}
|
|
4762
|
+
if (!await healthCheck(`${url}${healthPath}`, httpsAssets?.["tls/fullchain.pem"])) {
|
|
4323
4763
|
healthy = false;
|
|
4324
4764
|
break;
|
|
4325
4765
|
}
|
|
@@ -4606,7 +5046,7 @@ async function pollDeploymentStatus(token, deploymentId) {
|
|
|
4606
5046
|
if (deployment.readyState === "ERROR" || deployment.readyState === "CANCELED") {
|
|
4607
5047
|
return "error";
|
|
4608
5048
|
}
|
|
4609
|
-
await new Promise((
|
|
5049
|
+
await new Promise((resolve9) => setTimeout(resolve9, POLL_INTERVAL));
|
|
4610
5050
|
}
|
|
4611
5051
|
return "building";
|
|
4612
5052
|
}
|
|
@@ -5141,7 +5581,7 @@ var deployCredentials = {
|
|
|
5141
5581
|
saveAll: (entries) => saveCredentials(entries)
|
|
5142
5582
|
};
|
|
5143
5583
|
function mergeProvisionEnv(results, scan, options) {
|
|
5144
|
-
|
|
5584
|
+
const env = {};
|
|
5145
5585
|
const reachRequirements = scan?.middlewares?.filter((item) => item.configName === "reach") ?? [];
|
|
5146
5586
|
const reachProviders = reachRequirements.map((item) => item.config);
|
|
5147
5587
|
let hasReachOutput = false;
|
|
@@ -5156,13 +5596,16 @@ function mergeProvisionEnv(results, scan, options) {
|
|
|
5156
5596
|
});
|
|
5157
5597
|
if (targets.length) {
|
|
5158
5598
|
for (const target of targets)
|
|
5159
|
-
reachProviders[reachRequirements.indexOf(target)] = { ...provider, name: target.config.name ?? provider.name };
|
|
5599
|
+
reachProviders[reachRequirements.indexOf(target)] = { ...mergeManagedMiddlewareConfig(target, provider), name: target.config.name ?? provider.name };
|
|
5160
5600
|
} else {
|
|
5161
5601
|
reachProviders.push(provider);
|
|
5162
5602
|
}
|
|
5163
5603
|
}
|
|
5164
5604
|
}
|
|
5165
|
-
|
|
5605
|
+
for (const [name, value] of Object.entries(prov.envVars)) {
|
|
5606
|
+
const requirement = scan?.middlewares?.find((item) => item.envPrefix === name);
|
|
5607
|
+
env[name] = requirement ? JSON.stringify(mergeManagedMiddlewareConfig(requirement, JSON.parse(value))) : value;
|
|
5608
|
+
}
|
|
5166
5609
|
}
|
|
5167
5610
|
if (hasReachOutput)
|
|
5168
5611
|
env.HAI_REACH_PROVIDERS = JSON.stringify(reachProviders);
|
|
@@ -5583,7 +6026,8 @@ async function createDeploymentPlan(appDir, input, options = {}) {
|
|
|
5583
6026
|
}
|
|
5584
6027
|
}
|
|
5585
6028
|
const sidecar = provider.type === "docker-ssh" ? planSidecars(directory, projectName, app, provisionEnv, effective, config.container?.s3Engine, { dbPassword: "", storagePassword: "", ldapPassword: "" }) : void 0;
|
|
5586
|
-
const
|
|
6029
|
+
const https = provider.type === "docker-ssh" ? provider.expose.https : void 0;
|
|
6030
|
+
const gateway = !!artifact && !(artifact.primaryService === "serv" && artifact.hasWeb) && (sidecar?.services.includes("storage") || !!https && (artifact.primaryService === "serv" || app.isSvelteKit));
|
|
5587
6031
|
if (gateway && artifact.primaryService === "web" && !app.isSvelteKit)
|
|
5588
6032
|
return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_storageBackendRequired"));
|
|
5589
6033
|
if (gateway) {
|
|
@@ -5591,18 +6035,23 @@ async function createDeploymentPlan(appDir, input, options = {}) {
|
|
|
5591
6035
|
if (!baseline.required)
|
|
5592
6036
|
selected.push("gateway");
|
|
5593
6037
|
}
|
|
6038
|
+
if (https?.type === "letsencrypt") {
|
|
6039
|
+
available.push("certbot");
|
|
6040
|
+
if (!baseline.required)
|
|
6041
|
+
selected.push("certbot");
|
|
6042
|
+
}
|
|
5594
6043
|
const services = Object.entries(sidecar?.composeServices ?? {}).filter(([name]) => selected.includes(name));
|
|
5595
6044
|
return ok({
|
|
5596
6045
|
projectName,
|
|
5597
6046
|
root,
|
|
5598
|
-
|
|
6047
|
+
serviceDir: directory,
|
|
5599
6048
|
environment: environment.data.environment,
|
|
5600
|
-
target: provider.type === "docker-ssh" ? { provider: provider.type, host: provider.ssh?.host, port: provider.ssh?.port, directory: remoteDirectory, runtime: provider.remote.runtime, hostPort: provider.expose.hostPort } : { provider: provider.type },
|
|
6049
|
+
target: provider.type === "docker-ssh" ? { provider: provider.type, host: provider.ssh?.host, port: provider.ssh?.port, directory: remoteDirectory, runtime: provider.remote.runtime, hostPort: provider.expose.hostPort, https: https ? { type: https.type, domain: https.domain, port: https.port } : void 0 } : { provider: provider.type },
|
|
5601
6050
|
artifact: artifact ? { directory: artifactDir, version: artifact.appVersion, buildId: artifact.buildId, verified: true, images: artifact.images.map((image) => ({ name: image.name, reference: image.imageRef, sha256: image.sha256 })) } : void 0,
|
|
5602
6051
|
selected,
|
|
5603
6052
|
retained: available.filter((name) => !selected.includes(name)),
|
|
5604
6053
|
containers: provider.type === "docker-ssh" ? [...selected.filter((name) => images.includes(name) || name === "gateway").map((name) => `hai-${projectName}-${name}`), ...services.flatMap(([, service]) => typeof service.container_name === "string" ? [service.container_name] : [])] : [],
|
|
5605
|
-
composeManagedServices: services.filter(([, service]) => typeof service.container_name !== "string").map(([name]) => name),
|
|
6054
|
+
composeManagedServices: [...services.filter(([, service]) => typeof service.container_name !== "string").map(([name]) => name), ...selected.includes("certbot") ? ["certbot"] : []],
|
|
5606
6055
|
logicalVolumes: Object.keys(sidecar?.volumes ?? {}),
|
|
5607
6056
|
middleware: middlewares.map((item) => ({ service: item.service, backend: item.backend, configuration: item.envPrefix, action: selected.includes(item.service) ? effective.serviceModes?.[item.envPrefix] ?? effective.serviceModes?.[item.service] ?? "default" : "keep", provisioner: provisioners.get(item.service) })),
|
|
5608
6057
|
configuration: { files: [join(root, "hai.json"), ...configFiles].filter(existsSync), environmentKeys: Object.keys(environment.data.values).sort() },
|