feat: 建立 NC01 共享公网边缘
This commit is contained in:
+4
-53
@@ -159,9 +159,10 @@ testRuntime:
|
||||
exposure:
|
||||
serviceName: pikaoa-web
|
||||
serviceType: NodePort
|
||||
hostIP: 152.53.229.148
|
||||
hostIP: 127.0.0.1
|
||||
port: 32080
|
||||
publicBaseUrl: https://oa-dev.hwpod.com
|
||||
expectedA: 152.53.229.148
|
||||
observability:
|
||||
configRef: config/platform-infra/observability.yaml
|
||||
otlpEndpoint: otel-collector.platform-infra.svc.cluster.local:4317
|
||||
@@ -317,9 +318,10 @@ releaseRuntime:
|
||||
exposure:
|
||||
serviceName: pikaoa-web
|
||||
serviceType: NodePort
|
||||
hostIP: 152.53.229.148
|
||||
hostIP: 127.0.0.1
|
||||
port: 32096
|
||||
publicBaseUrl: https://oa.hwpod.com
|
||||
expectedA: 152.53.229.148
|
||||
observability:
|
||||
configRef: config/platform-infra/observability.yaml
|
||||
otlpEndpoint: otel-collector.platform-infra.svc.cluster.local:4317
|
||||
@@ -342,54 +344,6 @@ releaseRuntime:
|
||||
livenessPath: /healthz
|
||||
readinessPath: /healthz
|
||||
|
||||
publicEdge:
|
||||
target:
|
||||
node: NC01
|
||||
route: NC01
|
||||
publicAddress: 152.53.229.148
|
||||
caddy:
|
||||
image: caddy:2.10.2-alpine
|
||||
workDir: /opt/unidesk/pikaoa-edge
|
||||
composePath: /opt/unidesk/pikaoa-edge/docker-compose.yaml
|
||||
caddyfilePath: /opt/unidesk/pikaoa-edge/Caddyfile
|
||||
dataDir: /var/lib/unidesk/pikaoa-edge/caddy-data
|
||||
configDir: /var/lib/unidesk/pikaoa-edge/caddy-config
|
||||
containerName: pikaoa-edge
|
||||
httpPort: 80
|
||||
httpsPort: 443
|
||||
protocols:
|
||||
- h1
|
||||
- h2
|
||||
responseHeaderTimeoutSeconds: 60
|
||||
sites:
|
||||
production:
|
||||
hostname: oa.hwpod.com
|
||||
upstream: 127.0.0.1:32096
|
||||
healthPath: /healthz
|
||||
development:
|
||||
hostname: oa-dev.hwpod.com
|
||||
upstream: 127.0.0.1:32080
|
||||
healthPath: /healthz
|
||||
tcpPortRelease:
|
||||
settingsPath: /root/vpn-server/config/settings.yaml
|
||||
workDir: /root/vpn-server
|
||||
service: xray
|
||||
fromPort: 443
|
||||
toPort: 8447
|
||||
applyCommand: npm run apply
|
||||
legacyExposure:
|
||||
pk01:
|
||||
route: PK01
|
||||
caddyConfigPath: /etc/caddy/Caddyfile
|
||||
caddyServiceName: caddy
|
||||
managedBlock: pikaoa
|
||||
hostname: oa.pikapython.com
|
||||
frpc:
|
||||
route: NC01:k3s
|
||||
namespace: pikaoa
|
||||
deploymentName: pikaoa-frpc
|
||||
retirementBlocking: false
|
||||
|
||||
delivery:
|
||||
targets:
|
||||
NC01:
|
||||
@@ -497,6 +451,3 @@ delivery:
|
||||
employee:
|
||||
username: employee
|
||||
passwordSourceRef: ~/.unidesk/.env/pikaoa-employee-password.txt
|
||||
publicExposure:
|
||||
configRef: config/pikaoa.yaml#publicEdge
|
||||
site: production
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
version: 1
|
||||
kind: platform-infra-public-edge
|
||||
|
||||
metadata:
|
||||
id: nc01-public-edge
|
||||
owner: platform-infra
|
||||
relatedIssues:
|
||||
- 2181
|
||||
|
||||
defaults:
|
||||
targetId: NC01
|
||||
|
||||
targets:
|
||||
NC01:
|
||||
route: NC01
|
||||
publicAddress: 152.53.229.148
|
||||
listener:
|
||||
httpPort: 80
|
||||
httpsPort: 443
|
||||
protocols:
|
||||
- h1
|
||||
- h2
|
||||
runtime:
|
||||
image: caddy:2.10.2-alpine
|
||||
containerName: pikaoa-edge
|
||||
workDir: /opt/unidesk/pikaoa-edge
|
||||
composePath: /opt/unidesk/pikaoa-edge/docker-compose.yaml
|
||||
caddyfilePath: /opt/unidesk/pikaoa-edge/Caddyfile
|
||||
dataDir: /var/lib/unidesk/pikaoa-edge/caddy-data
|
||||
configDir: /var/lib/unidesk/pikaoa-edge/caddy-config
|
||||
responseHeaderTimeoutSeconds: 60
|
||||
sites:
|
||||
- id: pikaoa-development
|
||||
configRef: config/pikaoa.yaml
|
||||
path: testRuntime.targets.NC01
|
||||
- id: pikaoa-production
|
||||
configRef: config/pikaoa.yaml
|
||||
path: releaseRuntime.targets.NC01
|
||||
- id: hwlab-development
|
||||
configRef: config/hwlab-node-control-plane.yaml
|
||||
path: targets[id=nc01-v03].publicExposure
|
||||
- id: hwlab-production
|
||||
configRef: config/hwlab-node-control-plane.yaml
|
||||
path: targets[id=nc01-production].publicExposure
|
||||
- id: unidesk
|
||||
configRef: config/frontend.yaml
|
||||
path: publicExposure
|
||||
- id: webterm
|
||||
configRef: config/platform-infra/webterm.yaml
|
||||
path: targets[id=local-7683].publicExposure
|
||||
+15
-13
@@ -140,23 +140,25 @@
|
||||
- Argo Application `pikaoa-nc01` 自动同步 `pikaoa` namespace;
|
||||
- 禁止人工补建 PipelineRun 代替 source merge。
|
||||
- 生产使用 PK01 host PostgreSQL 独立 `pikaoa` database/role,不使用 namespace-local PostgreSQL。
|
||||
- 公网边缘由 `config/pikaoa.yaml#publicEdge` 唯一声明:
|
||||
- `oa.hwpod.com` 反向代理生产 NodePort;
|
||||
- `oa-dev.hwpod.com` 反向代理测试 NodePort;
|
||||
- NC01 Caddy 使用标准 TCP 80/443;
|
||||
- Hysteria 继续使用 UDP 443,不受 TCP listener 变更影响;
|
||||
- Xray Reality 使用 YAML 声明的独立 TCP 端口,不再占用 TCP 443;
|
||||
- 旧 `oa.pikapython.com` PK01 Caddy 托管块必须退役。
|
||||
- 公网边缘受控入口是:
|
||||
- PikaOA 公网 exposure 由 `config/pikaoa.yaml` 的产品 target 唯一声明:
|
||||
- `testRuntime.targets.NC01.exposure` 拥有 `oa-dev.hwpod.com` 与 NodePort `32080`;
|
||||
- `releaseRuntime.targets.NC01.exposure` 拥有 `oa.hwpod.com` 与 NodePort `32096`;
|
||||
- 产品 YAML 不再拥有 Caddy listener、workDir、证书目录或 Compose;
|
||||
- 平台聚合只通过 `configRef/path` 读取 exposure,不复制 hostname 或 upstream。
|
||||
- NC01 共享公网边缘由
|
||||
`config/platform-infra/nc01-public-edge.yaml` 唯一声明,受控入口是:
|
||||
|
||||
```bash
|
||||
bun scripts/cli.ts pikaoa public-edge plan
|
||||
bun scripts/cli.ts pikaoa public-edge apply --confirm
|
||||
bun scripts/cli.ts pikaoa public-edge status
|
||||
bun scripts/cli.ts platform-infra public-edge plan --target NC01
|
||||
bun scripts/cli.ts platform-infra public-edge apply --target NC01 --dry-run
|
||||
bun scripts/cli.ts platform-infra public-edge apply --target NC01 --confirm
|
||||
bun scripts/cli.ts platform-infra public-edge status --target NC01
|
||||
```
|
||||
|
||||
- `public-edge apply` 只按 managed marker 删除 PikaOA 旧边缘块;不得覆盖 PK01 其他 Caddy 站点。
|
||||
- 旧 FRPC 残留只产生 `blocking=false` warning,不阻塞新域名上线。
|
||||
- 共享边缘复用现有 `pikaoa-edge` 容器、workDir、Caddy data/config 和证书状态;
|
||||
禁止启动第二个 TCP `80/443` listener。
|
||||
- PikaOA 两站是 R9.1 已解析消费者;其他产品引用未迁移时,
|
||||
`plan/status` 精确披露,`apply` 拒绝 mutation。
|
||||
- 生产初始化模式是 `fresh-database-only`,不实现旧数据迁移、兼容或回滚。
|
||||
- 版本、审计和配置一致性异常只产生 `blocking=false` warning,不作为 MVP 业务门禁。
|
||||
- PR、构建或单测不能替代固定 NodePort 原入口验收。
|
||||
|
||||
@@ -10,6 +10,32 @@
|
||||
- UniDesk 拥有的 image version、namespace、endpoint、FRP port 与 profile 列表不得隐藏在 helper 常量里;外部工具要求的 TOML、JSON 和 env 仅是边缘渲染格式。
|
||||
- 新节点出网 bootstrap 遵循 `docs/reference/yaml-first-ops.md` 的零依赖 host proxy 边界,不得依赖尚未安装的 Docker、k3s、containerd 或包管理器网络能力。
|
||||
|
||||
## NC01 共享公网边缘
|
||||
|
||||
- `config/platform-infra/nc01-public-edge.yaml` 是 NC01 TCP `80/443` listener、
|
||||
Caddy runtime、workDir、Compose、TLS data/config 和站点聚合顺序的唯一真相。
|
||||
- 每个站点只声明 `configRef` 与 `path`:
|
||||
- hostname、upstream、DNS expectation 和 probe path 从产品 owning YAML 解析;
|
||||
- 共享 YAML、renderer 和产品 YAML 不得重复声明同一 hostname 或 upstream;
|
||||
- 路径缺失、对象形状不兼容、hostname 重复或 `expectedA` 不匹配时,
|
||||
`plan/status` 必须精确披露,`apply` 不得补隐藏默认值。
|
||||
- 共享边缘复用 `pikaoa-edge` 的 Caddy workDir、data、config、容器名和证书状态;
|
||||
禁止创建第二 Caddy、FRP 或 fallback listener。
|
||||
- `platform-infra public-edge apply --confirm` 每次重新读取 YAML 与运行面:
|
||||
- 先生成临时候选 Caddyfile;
|
||||
- 先执行 Caddy adapt 与 validate;
|
||||
- 校验成功后才安装并 reload;
|
||||
- reload 失败时恢复上一份有效 Caddyfile 与 Compose;
|
||||
- 不得基于旧 `status` 结果直接 mutation。
|
||||
- 受控入口:
|
||||
|
||||
```bash
|
||||
bun scripts/cli.ts platform-infra public-edge plan --target NC01
|
||||
bun scripts/cli.ts platform-infra public-edge apply --target NC01 --dry-run
|
||||
bun scripts/cli.ts platform-infra public-edge apply --target NC01 --confirm
|
||||
bun scripts/cli.ts platform-infra public-edge status --target NC01
|
||||
```
|
||||
|
||||
## Gitea 与 Pipelines-as-Code 边界
|
||||
|
||||
- Gitea mirror 与 Pipelines-as-Code 是 UniDesk 运维的 CI source/trigger 服务。`config/platform-infra/gitea.yaml` 和 `config/platform-infra/pipelines-as-code.yaml` 分别拥有 mirror/webhook 与 PaC Repository/consumer 配置;repo URL、snapshot、webhook、public exposure、FRP/Caddy port、token sourceRef 和 PaC params 不得隐藏在代码特例中。
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import type { UniDeskConfig } from "./config";
|
||||
import { repoRoot } from "./config";
|
||||
import { renderMachine } from "./cicd-render";
|
||||
import type { RenderedCliResult } from "./output";
|
||||
import { CliInputError } from "./output";
|
||||
import { capture, compactCapture, parseJsonOutput, readYamlRecord, sha256Fingerprint, shQuote } from "./platform-infra-ops-library";
|
||||
|
||||
type EdgeAction = "plan" | "apply" | "status";
|
||||
|
||||
interface EdgeOptions {
|
||||
action: EdgeAction;
|
||||
configPath: string;
|
||||
confirm: boolean;
|
||||
}
|
||||
|
||||
interface EdgeSite {
|
||||
id: string;
|
||||
hostname: string;
|
||||
upstream: string;
|
||||
healthPath: string;
|
||||
}
|
||||
|
||||
interface EdgeSpec {
|
||||
route: string;
|
||||
publicAddress: string;
|
||||
caddy: {
|
||||
image: string;
|
||||
workDir: string;
|
||||
composePath: string;
|
||||
caddyfilePath: string;
|
||||
dataDir: string;
|
||||
configDir: string;
|
||||
containerName: string;
|
||||
httpPort: number;
|
||||
httpsPort: number;
|
||||
protocols: string[];
|
||||
responseHeaderTimeoutSeconds: number;
|
||||
};
|
||||
sites: EdgeSite[];
|
||||
tcpPortRelease: {
|
||||
settingsPath: string;
|
||||
workDir: string;
|
||||
service: string;
|
||||
fromPort: number;
|
||||
toPort: number;
|
||||
applyCommand: string;
|
||||
};
|
||||
legacyExposure: {
|
||||
pk01: { route: string; caddyConfigPath: string; caddyServiceName: string; managedBlock: string; hostname: string };
|
||||
frpc: { route: string; namespace: string; deploymentName: string; retirementBlocking: boolean };
|
||||
};
|
||||
}
|
||||
|
||||
export function pikaoaPublicEdgeHelp(): Record<string, unknown> {
|
||||
return {
|
||||
ok: true,
|
||||
command: "pikaoa public-edge",
|
||||
usage: [
|
||||
"bun scripts/cli.ts pikaoa public-edge plan [--config path]",
|
||||
"bun scripts/cli.ts pikaoa public-edge apply [--config path] --confirm",
|
||||
"bun scripts/cli.ts pikaoa public-edge status [--config path]",
|
||||
],
|
||||
source: "config/pikaoa.yaml#publicEdge",
|
||||
guarantees: [
|
||||
"TCP 80/443、站点、upstream、Caddy 和 TCP 端口释放全部来自 owning YAML。",
|
||||
"apply 保留 UDP 443;只迁移 YAML 声明的 TCP 服务端口。",
|
||||
"旧 PK01 Caddy 托管块按 marker 精确删除;FRPC 退役仅为 non-blocking warning。",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function runPikaoaPublicEdgeCommand(config: UniDeskConfig, args: string[]): Promise<RenderedCliResult> {
|
||||
if (args.length === 0 || args.some((arg) => arg === "--help" || arg === "-h")) {
|
||||
return renderMachine("pikaoa public-edge", pikaoaPublicEdgeHelp(), "json");
|
||||
}
|
||||
const options = parseOptions(args);
|
||||
const spec = readSpec(options.configPath);
|
||||
if (options.action === "plan") return renderMachine("pikaoa public-edge plan", plan(spec, options.configPath), "json");
|
||||
if (options.action === "status") return renderMachine("pikaoa public-edge status", await status(config, spec), "json");
|
||||
if (!options.confirm) throw inputError("apply 必须显式提供 --confirm", "confirm-required", "--confirm");
|
||||
return renderMachine("pikaoa public-edge apply", await apply(config, spec), "json");
|
||||
}
|
||||
|
||||
function parseOptions(args: string[]): EdgeOptions {
|
||||
const action = args[0];
|
||||
if (action !== "plan" && action !== "apply" && action !== "status") {
|
||||
throw inputError("public-edge action 必须是 plan、apply 或 status", "invalid-action", action ?? "<missing>");
|
||||
}
|
||||
let configPath = resolve(repoRoot, "config/pikaoa.yaml");
|
||||
let confirm = false;
|
||||
for (let index = 1; index < args.length; index += 1) {
|
||||
const arg = args[index]!;
|
||||
if (arg === "--config") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw inputError("--config 需要参数", "missing-option-value", "--config");
|
||||
configPath = resolve(repoRoot, value);
|
||||
index += 1;
|
||||
} else if (arg === "--confirm") {
|
||||
confirm = true;
|
||||
} else {
|
||||
throw inputError(`不支持的 public-edge 参数:${arg}`, "unsupported-option", arg);
|
||||
}
|
||||
}
|
||||
if ((action === "plan" || action === "status") && confirm) throw inputError(`${action} 不接受 --confirm`, "confirm-not-allowed", "--confirm");
|
||||
return { action, configPath, confirm };
|
||||
}
|
||||
|
||||
function readSpec(path: string): EdgeSpec {
|
||||
if (!existsSync(path)) throw inputError(`找不到 owning YAML:${path}`, "config-not-found", "--config");
|
||||
const root = readYamlRecord(path, "pikaoa-platform-delivery");
|
||||
const edge = record(root.publicEdge, "publicEdge");
|
||||
const target = record(edge.target, "publicEdge.target");
|
||||
const caddy = record(edge.caddy, "publicEdge.caddy");
|
||||
const sites = record(edge.sites, "publicEdge.sites");
|
||||
const tcp = record(edge.tcpPortRelease, "publicEdge.tcpPortRelease");
|
||||
const legacy = record(edge.legacyExposure, "publicEdge.legacyExposure");
|
||||
const pk01 = record(legacy.pk01, "publicEdge.legacyExposure.pk01");
|
||||
const frpc = record(legacy.frpc, "publicEdge.legacyExposure.frpc");
|
||||
return {
|
||||
route: string(target.route, "publicEdge.target.route"),
|
||||
publicAddress: string(target.publicAddress, "publicEdge.target.publicAddress"),
|
||||
caddy: {
|
||||
image: string(caddy.image, "publicEdge.caddy.image"),
|
||||
workDir: string(caddy.workDir, "publicEdge.caddy.workDir"),
|
||||
composePath: string(caddy.composePath, "publicEdge.caddy.composePath"),
|
||||
caddyfilePath: string(caddy.caddyfilePath, "publicEdge.caddy.caddyfilePath"),
|
||||
dataDir: string(caddy.dataDir, "publicEdge.caddy.dataDir"),
|
||||
configDir: string(caddy.configDir, "publicEdge.caddy.configDir"),
|
||||
containerName: string(caddy.containerName, "publicEdge.caddy.containerName"),
|
||||
httpPort: integer(caddy.httpPort, "publicEdge.caddy.httpPort"),
|
||||
httpsPort: integer(caddy.httpsPort, "publicEdge.caddy.httpsPort"),
|
||||
protocols: stringList(caddy.protocols, "publicEdge.caddy.protocols"),
|
||||
responseHeaderTimeoutSeconds: integer(caddy.responseHeaderTimeoutSeconds, "publicEdge.caddy.responseHeaderTimeoutSeconds"),
|
||||
},
|
||||
sites: Object.entries(sites).map(([id, value]) => {
|
||||
const site = record(value, `publicEdge.sites.${id}`);
|
||||
return { id, hostname: hostname(site.hostname, `publicEdge.sites.${id}.hostname`), upstream: string(site.upstream, `publicEdge.sites.${id}.upstream`), healthPath: httpPath(site.healthPath, `publicEdge.sites.${id}.healthPath`) };
|
||||
}),
|
||||
tcpPortRelease: {
|
||||
settingsPath: string(tcp.settingsPath, "publicEdge.tcpPortRelease.settingsPath"),
|
||||
workDir: string(tcp.workDir, "publicEdge.tcpPortRelease.workDir"),
|
||||
service: string(tcp.service, "publicEdge.tcpPortRelease.service"),
|
||||
fromPort: integer(tcp.fromPort, "publicEdge.tcpPortRelease.fromPort"),
|
||||
toPort: integer(tcp.toPort, "publicEdge.tcpPortRelease.toPort"),
|
||||
applyCommand: string(tcp.applyCommand, "publicEdge.tcpPortRelease.applyCommand"),
|
||||
},
|
||||
legacyExposure: {
|
||||
pk01: {
|
||||
route: string(pk01.route, "publicEdge.legacyExposure.pk01.route"),
|
||||
caddyConfigPath: string(pk01.caddyConfigPath, "publicEdge.legacyExposure.pk01.caddyConfigPath"),
|
||||
caddyServiceName: string(pk01.caddyServiceName, "publicEdge.legacyExposure.pk01.caddyServiceName"),
|
||||
managedBlock: string(pk01.managedBlock, "publicEdge.legacyExposure.pk01.managedBlock"),
|
||||
hostname: hostname(pk01.hostname, "publicEdge.legacyExposure.pk01.hostname"),
|
||||
},
|
||||
frpc: {
|
||||
route: string(frpc.route, "publicEdge.legacyExposure.frpc.route"),
|
||||
namespace: string(frpc.namespace, "publicEdge.legacyExposure.frpc.namespace"),
|
||||
deploymentName: string(frpc.deploymentName, "publicEdge.legacyExposure.frpc.deploymentName"),
|
||||
retirementBlocking: boolean(frpc.retirementBlocking, "publicEdge.legacyExposure.frpc.retirementBlocking"),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function plan(spec: EdgeSpec, configPath: string): Record<string, unknown> {
|
||||
return {
|
||||
ok: true,
|
||||
mutation: false,
|
||||
configRef: `${configPath}#publicEdge`,
|
||||
target: { route: spec.route, publicAddress: spec.publicAddress },
|
||||
listeners: { tcp: [spec.caddy.httpPort, spec.caddy.httpsPort], udpPreserved: [spec.caddy.httpsPort] },
|
||||
sites: spec.sites,
|
||||
tcpPortRelease: { service: spec.tcpPortRelease.service, fromPort: spec.tcpPortRelease.fromPort, toPort: spec.tcpPortRelease.toPort },
|
||||
legacyExposure: { hostname: spec.legacyExposure.pk01.hostname, managedBlock: spec.legacyExposure.pk01.managedBlock, frpcRetirementBlocking: false },
|
||||
renderedFingerprint: sha256Fingerprint(`${renderCaddyfile(spec)}\n${renderCompose(spec)}`),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function apply(config: UniDeskConfig, spec: EdgeSpec): Promise<Record<string, unknown>> {
|
||||
const edgeResult = await capture(config, spec.route, ["sh"], edgeApplyScript(spec), { runtimeTimeoutMs: 240_000 });
|
||||
const edge = parseJsonOutput(edgeResult.stdout) ?? { ok: false, capture: compactCapture(edgeResult, { full: true }) };
|
||||
if (edgeResult.exitCode !== 0 || edge.ok === false) return { ok: false, mutation: true, edge, valuesPrinted: false };
|
||||
const legacyResult = await capture(config, spec.legacyExposure.pk01.route, ["sh"], legacyRemovalScript(spec), { runtimeTimeoutMs: 60_000 });
|
||||
const legacy = parseJsonOutput(legacyResult.stdout) ?? { ok: false, capture: compactCapture(legacyResult, { full: true }) };
|
||||
const frpcResult = await capture(config, spec.legacyExposure.frpc.route, ["kubectl", "get", "deployment", "-n", spec.legacyExposure.frpc.namespace, spec.legacyExposure.frpc.deploymentName, "-o", "name"], "");
|
||||
const warnings = frpcResult.exitCode === 0 ? [{ code: "legacy-frpc-still-present", blocking: false, object: frpcResult.stdout.trim() }] : [];
|
||||
return { ok: legacyResult.exitCode === 0 && legacy.ok !== false, mutation: true, edge, legacy, warnings, valuesPrinted: false };
|
||||
}
|
||||
|
||||
async function status(config: UniDeskConfig, spec: EdgeSpec): Promise<Record<string, unknown>> {
|
||||
const edgeResult = await capture(config, spec.route, ["sh"], edgeStatusScript(spec));
|
||||
const legacyResult = await capture(config, spec.legacyExposure.pk01.route, ["sh"], legacyStatusScript(spec));
|
||||
const edge = parseJsonOutput(edgeResult.stdout) ?? { ok: false, capture: compactCapture(edgeResult, { full: true }) };
|
||||
const legacy = parseJsonOutput(legacyResult.stdout) ?? { ok: false, capture: compactCapture(legacyResult, { full: true }) };
|
||||
return { ok: edgeResult.exitCode === 0 && legacyResult.exitCode === 0 && edge.ok !== false && legacy.ok !== false, mutation: false, edge, legacy, valuesPrinted: false };
|
||||
}
|
||||
|
||||
function edgeApplyScript(spec: EdgeSpec): string {
|
||||
const caddyfile = Buffer.from(renderCaddyfile(spec), "utf8").toString("base64");
|
||||
const compose = Buffer.from(renderCompose(spec), "utf8").toString("base64");
|
||||
return `set -u
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
settings=${shQuote(spec.tcpPortRelease.settingsPath)}
|
||||
python3 - "$settings" ${shQuote(spec.tcpPortRelease.service)} ${spec.tcpPortRelease.fromPort} ${spec.tcpPortRelease.toPort} >"$tmp/port.json" <<'PY'
|
||||
import json, sys
|
||||
path, service, old, new = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4])
|
||||
text = open(path, encoding="utf-8").read()
|
||||
lines = text.splitlines(keepends=True)
|
||||
inside = False
|
||||
changed = False
|
||||
seen = False
|
||||
for i, line in enumerate(lines):
|
||||
if line.rstrip() == f"{service}:":
|
||||
inside = True
|
||||
continue
|
||||
if inside and line and not line[0].isspace():
|
||||
inside = False
|
||||
if inside and line.strip() == f"port: {new}":
|
||||
seen = True
|
||||
if inside and line.strip() == f"port: {old}":
|
||||
prefix = line[:len(line) - len(line.lstrip())]
|
||||
newline = "\\n" if line.endswith("\\n") else ""
|
||||
lines[i] = f"{prefix}port: {new}{newline}"
|
||||
changed = True
|
||||
seen = True
|
||||
if not seen:
|
||||
raise SystemExit(f"{service} port is neither {old} nor {new}")
|
||||
if changed:
|
||||
open(path, "w", encoding="utf-8").write("".join(lines))
|
||||
print(json.dumps({"ok": True, "service": service, "fromPort": old, "toPort": new, "changed": changed}))
|
||||
PY
|
||||
port_rc=$?
|
||||
if [ "$port_rc" -eq 0 ] && grep -q '"changed": true' "$tmp/port.json"; then
|
||||
cd ${shQuote(spec.tcpPortRelease.workDir)}
|
||||
${spec.tcpPortRelease.applyCommand} >"$tmp/vpn.out" 2>"$tmp/vpn.err"
|
||||
vpn_rc=$?
|
||||
elif [ "$port_rc" -eq 0 ]; then
|
||||
: >"$tmp/vpn.out"; : >"$tmp/vpn.err"; vpn_rc=0
|
||||
else
|
||||
: >"$tmp/vpn.out"; : >"$tmp/vpn.err"; vpn_rc=1
|
||||
fi
|
||||
mkdir -p ${shQuote(spec.caddy.workDir)} ${shQuote(spec.caddy.dataDir)} ${shQuote(spec.caddy.configDir)}
|
||||
printf '%s' '${caddyfile}' | base64 -d >${shQuote(spec.caddy.caddyfilePath)}
|
||||
printf '%s' '${compose}' | base64 -d >${shQuote(spec.caddy.composePath)}
|
||||
if [ "$vpn_rc" -eq 0 ]; then
|
||||
docker compose -f ${shQuote(spec.caddy.composePath)} up -d >"$tmp/caddy.out" 2>"$tmp/caddy.err"
|
||||
caddy_rc=$?
|
||||
else
|
||||
: >"$tmp/caddy.out"; : >"$tmp/caddy.err"; caddy_rc=1
|
||||
fi
|
||||
sleep 2
|
||||
probe_rc=0
|
||||
for item in ${spec.sites.map((site) => shQuote(`${site.hostname}|${site.healthPath}`)).join(" ")}; do
|
||||
host="${'${item%%|*}'}"; path="/${'${item#*|}'}"; path="/${'${path#/}'}"
|
||||
curl --noproxy '*' -fsS --connect-timeout 5 --max-time 30 --resolve "$host:${spec.caddy.httpsPort}:127.0.0.1" "https://$host$path" >/dev/null 2>"$tmp/probe-$host.err" || probe_rc=1
|
||||
done
|
||||
python3 - "$port_rc" "$vpn_rc" "$caddy_rc" "$probe_rc" "$tmp/port.json" <<'PY'
|
||||
import json, sys
|
||||
rcs = [int(x) for x in sys.argv[1:5]]
|
||||
try:
|
||||
port = json.load(open(sys.argv[5], encoding="utf-8"))
|
||||
except Exception:
|
||||
port = {"ok": False}
|
||||
print(json.dumps({"ok": all(x == 0 for x in rcs), "steps": {"tcpPortRelease": {"exitCode": rcs[0], **port}, "vpnApply": {"exitCode": rcs[1], "outputRedacted": True}, "caddyApply": {"exitCode": rcs[2]}, "siteProbes": {"exitCode": rcs[3]}}, "valuesPrinted": False}))
|
||||
raise SystemExit(0 if all(x == 0 for x in rcs) else 1)
|
||||
PY
|
||||
`;
|
||||
}
|
||||
|
||||
function edgeStatusScript(spec: EdgeSpec): string {
|
||||
return `set -u
|
||||
container_rc=0; docker inspect -f '{{.State.Running}}' ${shQuote(spec.caddy.containerName)} 2>/dev/null | grep -qx true || container_rc=1
|
||||
ports_rc=0; ss -ltn | grep -Eq '[:.]${spec.caddy.httpPort}[[:space:]]' || ports_rc=1; ss -ltn | grep -Eq '[:.]${spec.caddy.httpsPort}[[:space:]]' || ports_rc=1
|
||||
sites_rc=0
|
||||
for item in ${spec.sites.map((site) => shQuote(`${site.hostname}|${site.healthPath}`)).join(" ")}; do
|
||||
host="${'${item%%|*}'}"; path="/${'${item#*|}'}"; path="/${'${path#/}'}"
|
||||
curl --noproxy '*' -fsS --connect-timeout 5 --max-time 20 --resolve "$host:${spec.caddy.httpsPort}:127.0.0.1" "https://$host$path" >/dev/null || sites_rc=1
|
||||
done
|
||||
xray_rc=0; ss -ltn | grep -Eq '[:.]${spec.tcpPortRelease.toPort}[[:space:]]' || xray_rc=1
|
||||
python3 - "$container_rc" "$ports_rc" "$sites_rc" "$xray_rc" <<'PY'
|
||||
import json, sys
|
||||
rcs = [int(x) for x in sys.argv[1:]]
|
||||
print(json.dumps({"ok": all(x == 0 for x in rcs), "container": {"exitCode": rcs[0]}, "tcpListeners": {"exitCode": rcs[1]}, "siteProbes": {"exitCode": rcs[2]}, "releasedService": {"exitCode": rcs[3]}, "valuesPrinted": False}))
|
||||
raise SystemExit(0 if all(x == 0 for x in rcs) else 1)
|
||||
PY
|
||||
`;
|
||||
}
|
||||
|
||||
function legacyRemovalScript(spec: EdgeSpec): string {
|
||||
const legacy = spec.legacyExposure.pk01;
|
||||
return `set -u
|
||||
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
|
||||
python3 - ${shQuote(legacy.caddyConfigPath)} ${shQuote(legacy.managedBlock)} "$tmp/Caddyfile" <<'PY'
|
||||
import re, sys
|
||||
path, marker, out = sys.argv[1:]
|
||||
text = open(path, encoding="utf-8").read()
|
||||
pattern = re.compile(rf"(?ms)^# BEGIN unidesk managed {re.escape(marker)}\\n.*?^# END unidesk managed {re.escape(marker)}\\n?")
|
||||
next_text, count = pattern.subn("", text)
|
||||
open(out, "w", encoding="utf-8").write(next_text)
|
||||
print(count)
|
||||
PY
|
||||
remove_rc=$?
|
||||
if [ "$remove_rc" -eq 0 ]; then caddy fmt --overwrite "$tmp/Caddyfile" >/dev/null 2>"$tmp/fmt.err"; fmt_rc=$?; else fmt_rc=1; fi
|
||||
if [ "$fmt_rc" -eq 0 ]; then caddy validate --config "$tmp/Caddyfile" >/dev/null 2>"$tmp/validate.err"; validate_rc=$?; else validate_rc=1; fi
|
||||
if [ "$validate_rc" -eq 0 ]; then install -m 0644 "$tmp/Caddyfile" ${shQuote(legacy.caddyConfigPath)}; install_rc=$?; else install_rc=1; fi
|
||||
if [ "$install_rc" -eq 0 ]; then systemctl reload ${shQuote(legacy.caddyServiceName)}; reload_rc=$?; else reload_rc=1; fi
|
||||
python3 - "$remove_rc" "$fmt_rc" "$validate_rc" "$install_rc" "$reload_rc" <<'PY'
|
||||
import json, sys
|
||||
rcs = [int(x) for x in sys.argv[1:]]
|
||||
print(json.dumps({"ok": all(x == 0 for x in rcs), "managedBlockRemoved": all(x == 0 for x in rcs), "steps": rcs, "valuesPrinted": False}))
|
||||
raise SystemExit(0 if all(x == 0 for x in rcs) else 1)
|
||||
PY
|
||||
`;
|
||||
}
|
||||
|
||||
function legacyStatusScript(spec: EdgeSpec): string {
|
||||
const legacy = spec.legacyExposure.pk01;
|
||||
return `set -u
|
||||
if grep -Fq ${shQuote(`# BEGIN unidesk managed ${legacy.managedBlock}`)} ${shQuote(legacy.caddyConfigPath)}; then present=true; rc=1; else present=false; rc=0; fi
|
||||
python3 - "$present" "$rc" <<'PY'
|
||||
import json, sys
|
||||
present = sys.argv[1] == "true"; rc = int(sys.argv[2])
|
||||
print(json.dumps({"ok": rc == 0, "legacyManagedBlockPresent": present, "valuesPrinted": False}))
|
||||
raise SystemExit(rc)
|
||||
PY
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCaddyfile(spec: EdgeSpec): string {
|
||||
const global = `{\n servers {\n protocols ${spec.caddy.protocols.join(" ")}\n }\n}\n`;
|
||||
return global + spec.sites.map((site) => `${site.hostname} {\n reverse_proxy ${site.upstream} {\n transport http {\n response_header_timeout ${spec.caddy.responseHeaderTimeoutSeconds}s\n }\n }\n}`).join("\n\n") + "\n";
|
||||
}
|
||||
|
||||
function renderCompose(spec: EdgeSpec): string {
|
||||
return `services:\n caddy:\n image: ${spec.caddy.image}\n container_name: ${spec.caddy.containerName}\n network_mode: host\n restart: unless-stopped\n volumes:\n - ${spec.caddy.caddyfilePath}:/etc/caddy/Caddyfile:ro\n - ${spec.caddy.dataDir}:/data\n - ${spec.caddy.configDir}:/config\n`;
|
||||
}
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw inputError(`${path} 必须是对象`, "invalid-config", path);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function string(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) throw inputError(`${path} 必须是非空字符串`, "invalid-config", path);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function integer(value: unknown, path: string): number {
|
||||
if (!Number.isInteger(value) || (value as number) < 1 || (value as number) > 65_535) throw inputError(`${path} 必须是 1..65535 整数`, "invalid-config", path);
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, path: string): boolean {
|
||||
if (typeof value !== "boolean") throw inputError(`${path} 必须是布尔值`, "invalid-config", path);
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringList(value: unknown, path: string): string[] {
|
||||
if (!Array.isArray(value) || value.length === 0) throw inputError(`${path} 必须是非空字符串数组`, "invalid-config", path);
|
||||
return value.map((item, index) => string(item, `${path}[${index}]`));
|
||||
}
|
||||
|
||||
function hostname(value: unknown, path: string): string {
|
||||
const result = string(value, path);
|
||||
if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(result)) throw inputError(`${path} 不是合法 hostname`, "invalid-config", path);
|
||||
return result;
|
||||
}
|
||||
|
||||
function httpPath(value: unknown, path: string): string {
|
||||
const result = string(value, path);
|
||||
if (!result.startsWith("/")) throw inputError(`${path} 必须以 / 开头`, "invalid-config", path);
|
||||
return result;
|
||||
}
|
||||
|
||||
function inputError(message: string, code: string, argument?: string): CliInputError {
|
||||
return new CliInputError(message, { code, argument, usage: "bun scripts/cli.ts pikaoa public-edge plan|apply|status" });
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import type { RenderedCliResult } from "./output";
|
||||
import { CliInputError } from "./output";
|
||||
import { capture, compactCapture, parseJsonOutput, readYamlRecord, sha256Fingerprint, shQuote } from "./platform-infra-ops-library";
|
||||
import { resolveConfigRef } from "./ops/config-refs";
|
||||
import { pikaoaPublicEdgeHelp, runPikaoaPublicEdgeCommand } from "./pikaoa-public-edge";
|
||||
import {
|
||||
pikaoaManifestForStep,
|
||||
placeholderPikaoaSecrets,
|
||||
@@ -237,10 +236,9 @@ export function pikaoaTestTargetHelp(): Record<string, unknown> {
|
||||
}
|
||||
|
||||
export async function runPikaoaCommand(config: UniDeskConfig, args: string[], remoteCapture: RemoteCapture = capture): Promise<RenderedCliResult> {
|
||||
if (args.length === 0) return renderMachine("pikaoa", { ok: true, commands: [pikaoaTestTargetHelp(), pikaoaPublicEdgeHelp()] }, "json");
|
||||
if (args[0] === "public-edge") return await runPikaoaPublicEdgeCommand(config, args.slice(1));
|
||||
if (args.length === 0) return renderMachine("pikaoa", { ok: true, commands: [pikaoaTestTargetHelp()] }, "json");
|
||||
if (args.some(isHelpToken)) return renderMachine("pikaoa test-target", pikaoaTestTargetHelp(), "json");
|
||||
if (args[0] !== "test-target") throw inputError("pikaoa 只支持 test-target 或 public-edge", "unsupported-pikaoa-command", "pikaoa", ["test-target", "public-edge"]);
|
||||
if (args[0] !== "test-target") throw inputError("pikaoa 只支持 test-target", "unsupported-pikaoa-command", "pikaoa", ["test-target"]);
|
||||
const options = parseOptions(args.slice(1));
|
||||
const selection = readSelection(options);
|
||||
if (options.action === "plan") return renderResult(planPayload(options, selection), options);
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
import type { UniDeskConfig } from "./config";
|
||||
import { renderMachine } from "./cicd-render";
|
||||
import type { RenderedCliResult } from "./output";
|
||||
import { CliInputError } from "./output";
|
||||
import {
|
||||
capture,
|
||||
compactCapture,
|
||||
parseJsonOutput,
|
||||
readYamlRecord,
|
||||
resolveRepoPath,
|
||||
sha256Fingerprint,
|
||||
shQuote,
|
||||
} from "./platform-infra-ops-library";
|
||||
|
||||
type PublicEdgeAction = "plan" | "apply" | "status";
|
||||
|
||||
interface PublicEdgeOptions {
|
||||
action: PublicEdgeAction;
|
||||
configPath: string;
|
||||
targetId: string | null;
|
||||
dryRun: boolean;
|
||||
confirm: boolean;
|
||||
}
|
||||
|
||||
export interface SiteReference {
|
||||
id: string;
|
||||
configRef: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ResolvedSite extends SiteReference {
|
||||
hostname: string;
|
||||
upstream: string;
|
||||
healthPath: string;
|
||||
expectedA: string;
|
||||
}
|
||||
|
||||
export interface PublicEdgeTarget {
|
||||
id: string;
|
||||
route: string;
|
||||
publicAddress: string;
|
||||
listener: {
|
||||
httpPort: number;
|
||||
httpsPort: number;
|
||||
protocols: string[];
|
||||
};
|
||||
runtime: {
|
||||
image: string;
|
||||
containerName: string;
|
||||
workDir: string;
|
||||
composePath: string;
|
||||
caddyfilePath: string;
|
||||
dataDir: string;
|
||||
configDir: string;
|
||||
responseHeaderTimeoutSeconds: number;
|
||||
};
|
||||
siteReferences: SiteReference[];
|
||||
}
|
||||
|
||||
interface ResolutionResult {
|
||||
sites: ResolvedSite[];
|
||||
unresolved: Array<SiteReference & { code: string; detail: string }>;
|
||||
}
|
||||
|
||||
export function publicEdgeHelp(): Record<string, unknown> {
|
||||
return {
|
||||
ok: true,
|
||||
command: "platform-infra public-edge",
|
||||
usage: [
|
||||
"bun scripts/cli.ts platform-infra public-edge plan --target NC01",
|
||||
"bun scripts/cli.ts platform-infra public-edge apply --target NC01 --dry-run",
|
||||
"bun scripts/cli.ts platform-infra public-edge apply --target NC01 --confirm",
|
||||
"bun scripts/cli.ts platform-infra public-edge status --target NC01",
|
||||
],
|
||||
source: "config/platform-infra/nc01-public-edge.yaml",
|
||||
guarantees: [
|
||||
"平台 YAML 只拥有 listener、Caddy runtime 和 site 聚合顺序。",
|
||||
"hostname 与 upstream 只从产品 owning YAML 的 configRef/path 解析。",
|
||||
"apply 每次重新读取 YAML 与运行面,缺失引用时拒绝 mutation。",
|
||||
"候选 Caddyfile 先 adapt/validate,reload 失败时恢复上一份配置。",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function runPlatformInfraPublicEdgeCommand(config: UniDeskConfig, args: string[]): Promise<RenderedCliResult> {
|
||||
if (args.length === 0 || args.some((arg) => arg === "--help" || arg === "-h")) {
|
||||
return renderMachine("platform-infra public-edge", publicEdgeHelp(), "json");
|
||||
}
|
||||
const options = parseOptions(args);
|
||||
const root = readConfig(options.configPath);
|
||||
const target = readTarget(root, options.targetId);
|
||||
const resolution = resolveSites(target);
|
||||
if (options.action === "plan") {
|
||||
const result = planResult(options.configPath, target, resolution, "plan");
|
||||
return renderMachine("platform-infra public-edge plan", result, "json", result.ok === true);
|
||||
}
|
||||
if (options.action === "apply" && options.dryRun) {
|
||||
const result = planResult(options.configPath, target, resolution, "dry-run");
|
||||
return renderMachine("platform-infra public-edge apply", result, "json", result.ok === true);
|
||||
}
|
||||
if (options.action === "status") {
|
||||
const result = await status(config, options.configPath, target, resolution);
|
||||
return renderMachine("platform-infra public-edge status", result, "json", result.ok === true);
|
||||
}
|
||||
if (resolution.unresolved.length > 0) {
|
||||
const result = {
|
||||
...planResult(options.configPath, target, resolution, "blocked"),
|
||||
mutation: false,
|
||||
error: "存在缺失或不兼容的产品 owning YAML 引用,apply 未执行。",
|
||||
};
|
||||
return renderMachine("platform-infra public-edge apply", result, "json", false);
|
||||
}
|
||||
const result = await apply(config, options.configPath, target, resolution.sites);
|
||||
return renderMachine("platform-infra public-edge apply", result, "json", result.ok === true);
|
||||
}
|
||||
|
||||
function parseOptions(args: string[]): PublicEdgeOptions {
|
||||
const action = args[0];
|
||||
if (action !== "plan" && action !== "apply" && action !== "status") {
|
||||
throw inputError("public-edge action 必须是 plan、apply 或 status", "invalid-action", action ?? "<missing>");
|
||||
}
|
||||
let configPath = resolveRepoPath("config/platform-infra/nc01-public-edge.yaml");
|
||||
let targetId: string | null = null;
|
||||
let dryRun = false;
|
||||
let confirm = false;
|
||||
for (let index = 1; index < args.length; index += 1) {
|
||||
const arg = args[index]!;
|
||||
if (arg === "--config" || arg === "--target") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw inputError(`${arg} 需要参数`, "missing-option-value", arg);
|
||||
if (arg === "--config") configPath = resolveRepoPath(value);
|
||||
else targetId = value;
|
||||
index += 1;
|
||||
} else if (arg === "--dry-run") {
|
||||
dryRun = true;
|
||||
} else if (arg === "--confirm") {
|
||||
confirm = true;
|
||||
} else {
|
||||
throw inputError(`不支持的 public-edge 参数:${arg}`, "unsupported-option", arg);
|
||||
}
|
||||
}
|
||||
if (action !== "apply" && (dryRun || confirm)) throw inputError(`${action} 不接受 mutation 参数`, "mutation-option-not-allowed");
|
||||
if (action === "apply" && dryRun === confirm) throw inputError("apply 必须且只能选择 --dry-run 或 --confirm", "apply-mode-required");
|
||||
return { action, configPath, targetId, dryRun, confirm };
|
||||
}
|
||||
|
||||
function readConfig(configPath: string): Record<string, unknown> {
|
||||
if (!existsSync(configPath)) throw inputError(`找不到 owning YAML:${configPath}`, "config-not-found", "--config");
|
||||
return readYamlRecord(configPath, "platform-infra-public-edge");
|
||||
}
|
||||
|
||||
function readTarget(root: Record<string, unknown>, requestedTargetId: string | null): PublicEdgeTarget {
|
||||
const defaults = record(root.defaults, "defaults");
|
||||
const targets = record(root.targets, "targets");
|
||||
const targetId = requestedTargetId ?? string(defaults.targetId, "defaults.targetId");
|
||||
const target = record(targets[targetId], `targets.${targetId}`);
|
||||
const listener = record(target.listener, `targets.${targetId}.listener`);
|
||||
const runtime = record(target.runtime, `targets.${targetId}.runtime`);
|
||||
const sites = array(target.sites, `targets.${targetId}.sites`);
|
||||
return {
|
||||
id: targetId,
|
||||
route: string(target.route, `targets.${targetId}.route`),
|
||||
publicAddress: ipv4(target.publicAddress, `targets.${targetId}.publicAddress`),
|
||||
listener: {
|
||||
httpPort: port(listener.httpPort, `targets.${targetId}.listener.httpPort`),
|
||||
httpsPort: port(listener.httpsPort, `targets.${targetId}.listener.httpsPort`),
|
||||
protocols: stringArray(listener.protocols, `targets.${targetId}.listener.protocols`),
|
||||
},
|
||||
runtime: {
|
||||
image: string(runtime.image, `targets.${targetId}.runtime.image`),
|
||||
containerName: string(runtime.containerName, `targets.${targetId}.runtime.containerName`),
|
||||
workDir: absolutePath(runtime.workDir, `targets.${targetId}.runtime.workDir`),
|
||||
composePath: absolutePath(runtime.composePath, `targets.${targetId}.runtime.composePath`),
|
||||
caddyfilePath: absolutePath(runtime.caddyfilePath, `targets.${targetId}.runtime.caddyfilePath`),
|
||||
dataDir: absolutePath(runtime.dataDir, `targets.${targetId}.runtime.dataDir`),
|
||||
configDir: absolutePath(runtime.configDir, `targets.${targetId}.runtime.configDir`),
|
||||
responseHeaderTimeoutSeconds: positiveInteger(runtime.responseHeaderTimeoutSeconds, `targets.${targetId}.runtime.responseHeaderTimeoutSeconds`),
|
||||
},
|
||||
siteReferences: sites.map((value, index) => {
|
||||
const site = record(value, `targets.${targetId}.sites[${index}]`);
|
||||
return {
|
||||
id: string(site.id, `targets.${targetId}.sites[${index}].id`),
|
||||
configRef: repoYamlPath(site.configRef, `targets.${targetId}.sites[${index}].configRef`),
|
||||
path: string(site.path, `targets.${targetId}.sites[${index}].path`),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSites(target: PublicEdgeTarget): ResolutionResult {
|
||||
const sites: ResolvedSite[] = [];
|
||||
const unresolved: ResolutionResult["unresolved"] = [];
|
||||
const hostnames = new Map<string, string>();
|
||||
for (const reference of target.siteReferences) {
|
||||
try {
|
||||
const ownerPath = resolveRepoPath(reference.configRef);
|
||||
if (!existsSync(ownerPath)) throw new Error(`configRef 文件不存在:${reference.configRef}`);
|
||||
const owner = resolveObjectPath(readYamlRecord(ownerPath), reference.path);
|
||||
const exposure = exposureObject(owner);
|
||||
const publicBaseUrl = string(exposure.publicBaseUrl, `${reference.configRef}#${reference.path}.publicBaseUrl`);
|
||||
const url = new URL(publicBaseUrl);
|
||||
if (url.protocol !== "https:") throw new Error(`publicBaseUrl 必须使用 https:${publicBaseUrl}`);
|
||||
const dns = optionalRecord(exposure.dns);
|
||||
const hostname = hostnameValue(dns?.hostname ?? url.hostname, `${reference.configRef}#${reference.path}.hostname`);
|
||||
if (url.hostname !== hostname) throw new Error(`publicBaseUrl hostname 与 dns.hostname 不一致:${url.hostname} != ${hostname}`);
|
||||
const expectedA = ipv4(dns?.expectedA ?? exposure.expectedA, `${reference.configRef}#${reference.path}.expectedA`);
|
||||
if (expectedA !== target.publicAddress) throw new Error(`expectedA 尚未迁移到 ${target.publicAddress},当前为 ${expectedA}`);
|
||||
const upstream = resolveUpstream(exposure, `${reference.configRef}#${reference.path}`);
|
||||
const healthPath = resolveHealthPath(owner, exposure, `${reference.configRef}#${reference.path}`);
|
||||
const previous = hostnames.get(hostname);
|
||||
if (previous !== undefined) throw new Error(`hostname 与 site ${previous} 重复:${hostname}`);
|
||||
hostnames.set(hostname, reference.id);
|
||||
sites.push({ ...reference, hostname, upstream, healthPath, expectedA });
|
||||
} catch (error) {
|
||||
unresolved.push({ ...reference, code: "config-ref-unresolved", detail: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
return { sites, unresolved };
|
||||
}
|
||||
|
||||
function resolveObjectPath(root: unknown, path: string): Record<string, unknown> {
|
||||
let value: unknown = root;
|
||||
for (const segment of path.split(".")) {
|
||||
const match = /^([A-Za-z0-9_-]+)(?:\[id=([A-Za-z0-9_-]+)\])?$/u.exec(segment);
|
||||
if (match === null) throw new Error(`path segment 不合法:${segment}`);
|
||||
const key = match[1]!;
|
||||
value = record(value, path)[key];
|
||||
if (match[2] !== undefined) {
|
||||
const values = array(value, `${path}.${key}`);
|
||||
value = values.find((item) => optionalRecord(item)?.id === match[2]);
|
||||
if (value === undefined) throw new Error(`path 未找到 id=${match[2]}:${path}`);
|
||||
}
|
||||
if (value === undefined) throw new Error(`path 不存在:${path}`);
|
||||
}
|
||||
return record(value, path);
|
||||
}
|
||||
|
||||
function exposureObject(owner: Record<string, unknown>): Record<string, unknown> {
|
||||
if (optionalRecord(owner.publicExposure) !== null) return record(owner.publicExposure, "publicExposure");
|
||||
if (optionalRecord(owner.exposure) !== null) return record(owner.exposure, "exposure");
|
||||
return owner;
|
||||
}
|
||||
|
||||
function resolveUpstream(exposure: Record<string, unknown>, label: string): string {
|
||||
if (typeof exposure.upstream === "string") return upstreamValue(exposure.upstream, `${label}.upstream`);
|
||||
const upstream = optionalRecord(exposure.upstream);
|
||||
if (upstream !== null) return upstreamValue(`${string(upstream.host, `${label}.upstream.host`)}:${port(upstream.port, `${label}.upstream.port`)}`, `${label}.upstream`);
|
||||
return upstreamValue(`${string(exposure.hostIP, `${label}.hostIP`)}:${port(exposure.port, `${label}.port`)}`, `${label}.upstream`);
|
||||
}
|
||||
|
||||
function resolveHealthPath(owner: Record<string, unknown>, exposure: Record<string, unknown>, label: string): string {
|
||||
const exposureProbes = optionalRecord(exposure.probes);
|
||||
if (exposureProbes !== null && exposureProbes.healthPath !== undefined) return httpPath(exposureProbes.healthPath, `${label}.probes.healthPath`);
|
||||
const probes = optionalRecord(owner.probes);
|
||||
if (probes !== null) {
|
||||
if (probes.healthPath !== undefined) return httpPath(probes.healthPath, `${label}.probes.healthPath`);
|
||||
const web = optionalRecord(probes.web);
|
||||
if (web !== null && web.livenessPath !== undefined) return httpPath(web.livenessPath, `${label}.probes.web.livenessPath`);
|
||||
}
|
||||
throw new Error(`${label} 缺少 probes.healthPath 或 probes.web.livenessPath`);
|
||||
}
|
||||
|
||||
function planResult(configPath: string, target: PublicEdgeTarget, resolution: ResolutionResult, mode: string): Record<string, unknown> & { ok: boolean } {
|
||||
const complete = resolution.unresolved.length === 0;
|
||||
const rendered = complete ? renderPublicEdgeArtifacts(target, resolution.sites) : null;
|
||||
return {
|
||||
ok: complete,
|
||||
action: "platform-infra-public-edge-plan",
|
||||
mode,
|
||||
mutation: false,
|
||||
configRef: `${configPath}#targets.${target.id}`,
|
||||
target: targetSummary(target),
|
||||
sites: resolution.sites,
|
||||
unresolved: resolution.unresolved,
|
||||
rendered: rendered === null ? null : {
|
||||
caddyfileFingerprint: sha256Fingerprint(rendered.caddyfile),
|
||||
composeFingerprint: sha256Fingerprint(rendered.compose),
|
||||
siteCount: resolution.sites.length,
|
||||
},
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function apply(config: UniDeskConfig, configPath: string, target: PublicEdgeTarget, sites: ResolvedSite[]): Promise<Record<string, unknown>> {
|
||||
const artifacts = renderPublicEdgeArtifacts(target, sites);
|
||||
const result = await capture(config, target.route, ["sh"], applyScript(target, sites, artifacts), { runtimeTimeoutMs: 120_000 });
|
||||
const runtime = parseJsonOutput(result.stdout) ?? { ok: false, capture: compactCapture(result, { full: true }) };
|
||||
return {
|
||||
ok: result.exitCode === 0 && runtime.ok !== false,
|
||||
action: "platform-infra-public-edge-apply",
|
||||
mutation: result.exitCode === 0 && runtime.ok !== false,
|
||||
configRef: `${configPath}#targets.${target.id}`,
|
||||
target: targetSummary(target),
|
||||
sites,
|
||||
runtime,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function status(config: UniDeskConfig, configPath: string, target: PublicEdgeTarget, resolution: ResolutionResult): Promise<Record<string, unknown>> {
|
||||
const artifacts = resolution.unresolved.length === 0 ? renderPublicEdgeArtifacts(target, resolution.sites) : null;
|
||||
const result = await capture(config, target.route, ["sh"], statusScript(target, resolution.sites, artifacts?.caddyfile ?? null));
|
||||
const runtime = parseJsonOutput(result.stdout) ?? { ok: false, capture: compactCapture(result, { full: true }) };
|
||||
const ok = resolution.unresolved.length === 0 && result.exitCode === 0 && runtime.ok !== false;
|
||||
return {
|
||||
ok,
|
||||
action: "platform-infra-public-edge-status",
|
||||
mutation: false,
|
||||
configRef: `${configPath}#targets.${target.id}`,
|
||||
target: targetSummary(target),
|
||||
sites: resolution.sites,
|
||||
unresolved: resolution.unresolved,
|
||||
runtime,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderPublicEdgeArtifacts(target: PublicEdgeTarget, sites: ResolvedSite[]): { caddyfile: string; compose: string } {
|
||||
const protocols = target.listener.protocols.join(" ");
|
||||
const global = `\{\n\tservers \{\n\t\tprotocols ${protocols}\n\t\}\n\}\n`;
|
||||
const blocks = sites.map((site) => `${site.hostname} \{\n\treverse_proxy ${site.upstream} \{\n\t\ttransport http \{\n\t\t\tresponse_header_timeout ${target.runtime.responseHeaderTimeoutSeconds}s\n\t\t\}\n\t\}\n\}\n`).join("\n");
|
||||
const compose = `services:\n caddy:\n image: ${target.runtime.image}\n container_name: ${target.runtime.containerName}\n network_mode: host\n restart: unless-stopped\n volumes:\n - ${target.runtime.caddyfilePath}:/etc/caddy/Caddyfile:ro\n - ${target.runtime.dataDir}:/data\n - ${target.runtime.configDir}:/config\n`;
|
||||
return { caddyfile: `${global}\n${blocks}`, compose };
|
||||
}
|
||||
|
||||
function applyScript(target: PublicEdgeTarget, sites: ResolvedSite[], artifacts: { caddyfile: string; compose: string }): string {
|
||||
const caddyfile = Buffer.from(artifacts.caddyfile, "utf8").toString("base64");
|
||||
const compose = Buffer.from(artifacts.compose, "utf8").toString("base64");
|
||||
const dnsItems = sites.map((site) => shQuote(`${site.hostname}|${site.expectedA}`)).join(" ");
|
||||
const probeItems = sites.map((site) => shQuote(`${site.hostname}|${site.healthPath}`)).join(" ");
|
||||
return `set -u
|
||||
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
|
||||
printf '%s' ${shQuote(caddyfile)} | base64 -d >"$tmp/Caddyfile"
|
||||
printf '%s' ${shQuote(compose)} | base64 -d >"$tmp/docker-compose.yaml"
|
||||
container_rc=0; docker inspect -f '{{.State.Running}}' ${shQuote(target.runtime.containerName)} 2>/dev/null | grep -qx true || container_rc=1
|
||||
listeners_rc=0; ss -ltn | grep -Eq '[:.]${target.listener.httpPort}[[:space:]]' || listeners_rc=1; ss -ltn | grep -Eq '[:.]${target.listener.httpsPort}[[:space:]]' || listeners_rc=1
|
||||
dns_rc=0
|
||||
for item in ${dnsItems}; do host="${"${item%%|*}"}"; expected="${"${item#*|}"}"; getent ahostsv4 "$host" | awk '{print $1}' | grep -Fxq "$expected" || dns_rc=1; done
|
||||
adapt_rc=1; validate_rc=1
|
||||
if [ "$container_rc" -eq 0 ] && [ "$listeners_rc" -eq 0 ] && [ "$dns_rc" -eq 0 ]; then
|
||||
docker run --rm -v "$tmp/Caddyfile:/etc/caddy/Caddyfile:ro" ${shQuote(target.runtime.image)} caddy adapt --validate --config /etc/caddy/Caddyfile >/dev/null 2>"$tmp/adapt.err"; adapt_rc=$?
|
||||
if [ "$adapt_rc" -eq 0 ]; then docker run --rm -v "$tmp/Caddyfile:/etc/caddy/Caddyfile:ro" ${shQuote(target.runtime.image)} caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>"$tmp/validate.err"; validate_rc=$?; fi
|
||||
fi
|
||||
install_rc=1; reload_rc=1; rollback_rc=0; probe_rc=1
|
||||
if [ "$validate_rc" -eq 0 ]; then
|
||||
mkdir -p ${shQuote(target.runtime.workDir)} ${shQuote(target.runtime.dataDir)} ${shQuote(target.runtime.configDir)}
|
||||
cp ${shQuote(target.runtime.caddyfilePath)} "$tmp/Caddyfile.previous" && cp ${shQuote(target.runtime.composePath)} "$tmp/docker-compose.previous.yaml"
|
||||
install -m 0644 "$tmp/docker-compose.yaml" ${shQuote(`${target.runtime.composePath}.next`)} && mv ${shQuote(`${target.runtime.composePath}.next`)} ${shQuote(target.runtime.composePath)}
|
||||
compose_rc=$?
|
||||
if [ "$compose_rc" -eq 0 ]; then cat "$tmp/Caddyfile" >${shQuote(target.runtime.caddyfilePath)}; install_rc=$?; fi
|
||||
if [ "$install_rc" -eq 0 ]; then docker exec ${shQuote(target.runtime.containerName)} caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>"$tmp/reload.err"; reload_rc=$?; fi
|
||||
if [ "$reload_rc" -ne 0 ]; then
|
||||
cat "$tmp/Caddyfile.previous" >${shQuote(target.runtime.caddyfilePath)} || rollback_rc=1
|
||||
install -m 0644 "$tmp/docker-compose.previous.yaml" ${shQuote(target.runtime.composePath)} || rollback_rc=1
|
||||
docker exec ${shQuote(target.runtime.containerName)} caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>>"$tmp/reload.err" || rollback_rc=1
|
||||
else
|
||||
probe_rc=0
|
||||
for item in ${probeItems}; do host="${"${item%%|*}"}"; path="/${"${item#*|}"}"; path="/${"${path#/}"}"; curl --noproxy '*' -fsS --connect-timeout 5 --max-time 20 --resolve "$host:${target.listener.httpsPort}:127.0.0.1" "https://$host$path" >/dev/null || probe_rc=1; done
|
||||
fi
|
||||
fi
|
||||
python3 - "$container_rc" "$listeners_rc" "$dns_rc" "$adapt_rc" "$validate_rc" "$install_rc" "$reload_rc" "$rollback_rc" "$probe_rc" <<'PY'
|
||||
import json, sys
|
||||
names = ["container", "listeners", "dns", "adapt", "validate", "install", "reload", "rollback", "probes"]
|
||||
values = [int(value) for value in sys.argv[1:]]
|
||||
steps = {name: {"exitCode": value} for name, value in zip(names, values)}
|
||||
ok = all(value == 0 for value in values)
|
||||
print(json.dumps({"ok": ok, "steps": steps, "valuesPrinted": False}))
|
||||
raise SystemExit(0 if ok else 1)
|
||||
PY
|
||||
`;
|
||||
}
|
||||
|
||||
function statusScript(target: PublicEdgeTarget, sites: ResolvedSite[], desiredCaddyfile: string | null): string {
|
||||
const probeItems = sites.map((site) => shQuote(`${site.hostname}|${site.healthPath}|${site.expectedA}`)).join(" ");
|
||||
const desiredFingerprint = desiredCaddyfile === null ? "" : sha256Fingerprint(desiredCaddyfile);
|
||||
return `set -u
|
||||
container_rc=0; docker inspect -f '{{.State.Running}}' ${shQuote(target.runtime.containerName)} 2>/dev/null | grep -qx true || container_rc=1
|
||||
listeners_rc=0; ss -ltn | grep -Eq '[:.]${target.listener.httpPort}[[:space:]]' || listeners_rc=1; ss -ltn | grep -Eq '[:.]${target.listener.httpsPort}[[:space:]]' || listeners_rc=1
|
||||
validate_rc=0; docker exec ${shQuote(target.runtime.containerName)} caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>/dev/null || validate_rc=1
|
||||
sites_rc=0; dns_rc=0
|
||||
for item in ${probeItems}; do
|
||||
host="${"${item%%|*}"}"; rest="${"${item#*|}"}"; path="/${"${rest%%|*}"}"; path="/${"${path#/}"}"; expected="${"${rest#*|}"}"
|
||||
getent ahostsv4 "$host" | awk '{print $1}' | grep -Fxq "$expected" || dns_rc=1
|
||||
curl --noproxy '*' -fsS --connect-timeout 5 --max-time 20 --resolve "$host:${target.listener.httpsPort}:127.0.0.1" "https://$host$path" >/dev/null || sites_rc=1
|
||||
done
|
||||
current_sha=""; if [ -f ${shQuote(target.runtime.caddyfilePath)} ]; then current_sha="sha256:$(sha256sum ${shQuote(target.runtime.caddyfilePath)} | awk '{print $1}')"; fi
|
||||
python3 - "$container_rc" "$listeners_rc" "$validate_rc" "$dns_rc" "$sites_rc" "$current_sha" ${shQuote(desiredFingerprint)} <<'PY'
|
||||
import json, sys
|
||||
container, listeners, validate, dns, sites = [int(value) for value in sys.argv[1:6]]
|
||||
current, desired = sys.argv[6:8]
|
||||
ok = all(value == 0 for value in [container, listeners, validate, dns, sites])
|
||||
print(json.dumps({"ok": ok, "container": {"exitCode": container}, "listeners": {"exitCode": listeners}, "validate": {"exitCode": validate}, "dns": {"exitCode": dns}, "siteProbes": {"exitCode": sites}, "config": {"currentFingerprint": current or None, "desiredFingerprint": desired or None, "matchesDesired": bool(desired) and current == desired}, "valuesPrinted": False}))
|
||||
raise SystemExit(0 if ok else 1)
|
||||
PY
|
||||
`;
|
||||
}
|
||||
|
||||
function targetSummary(target: PublicEdgeTarget): Record<string, unknown> {
|
||||
return {
|
||||
id: target.id,
|
||||
route: target.route,
|
||||
publicAddress: target.publicAddress,
|
||||
listeners: [target.listener.httpPort, target.listener.httpsPort],
|
||||
containerName: target.runtime.containerName,
|
||||
workDir: target.runtime.workDir,
|
||||
caddyfilePath: target.runtime.caddyfilePath,
|
||||
composePath: target.runtime.composePath,
|
||||
dataDir: target.runtime.dataDir,
|
||||
configDir: target.runtime.configDir,
|
||||
};
|
||||
}
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} 必须是对象`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function optionalRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
function array(value: unknown, path: string): unknown[] {
|
||||
if (!Array.isArray(value)) throw new Error(`${path} 必须是数组`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path} 必须是非空字符串`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function stringArray(value: unknown, path: string): string[] {
|
||||
if (!Array.isArray(value) || value.length === 0) throw new Error(`${path} 必须是非空字符串数组`);
|
||||
return value.map((item, index) => string(item, `${path}[${index}]`));
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, path: string): number {
|
||||
if (!Number.isInteger(value) || (value as number) < 1) throw new Error(`${path} 必须是正整数`);
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function port(value: unknown, path: string): number {
|
||||
const result = positiveInteger(value, path);
|
||||
if (result > 65_535) throw new Error(`${path} 必须小于等于 65535`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function ipv4(value: unknown, path: string): string {
|
||||
const result = string(value, path);
|
||||
const parts = result.split(".");
|
||||
if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/u.test(part) || Number(part) > 255)) throw new Error(`${path} 必须是 IPv4 地址`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function hostnameValue(value: unknown, path: string): string {
|
||||
const result = string(value, path).toLowerCase();
|
||||
if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(result)) throw new Error(`${path} 不是合法 hostname`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function upstreamValue(value: unknown, path: string): string {
|
||||
const result = string(value, path);
|
||||
if (!/^(?:[A-Za-z0-9.-]+|\[[0-9a-fA-F:]+\]):[1-9][0-9]{0,4}$/u.test(result)) throw new Error(`${path} 必须是 host:port`);
|
||||
const parsedPort = Number(result.slice(result.lastIndexOf(":") + 1));
|
||||
if (parsedPort > 65_535) throw new Error(`${path} port 必须小于等于 65535`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function httpPath(value: unknown, path: string): string {
|
||||
const result = string(value, path);
|
||||
if (!result.startsWith("/")) throw new Error(`${path} 必须以 / 开头`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function absolutePath(value: unknown, path: string): string {
|
||||
const result = string(value, path);
|
||||
if (!result.startsWith("/")) throw new Error(`${path} 必须是绝对路径`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function repoYamlPath(value: unknown, path: string): string {
|
||||
const result = string(value, path);
|
||||
if (result.startsWith("/") || result.includes("..") || !result.endsWith(".yaml")) throw new Error(`${path} 必须是 repo-relative YAML 路径`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function inputError(message: string, code: string, argument?: string): CliInputError {
|
||||
return new CliInputError(message, { code, argument, usage: "bun scripts/cli.ts platform-infra public-edge plan|apply|status --target NC01" });
|
||||
}
|
||||
@@ -400,6 +400,7 @@ export function platformInfraHelp(): unknown {
|
||||
"langbot",
|
||||
"n8n",
|
||||
"selfmedia-voice",
|
||||
"public-edge",
|
||||
"webterm",
|
||||
"wechat-archive",
|
||||
"observability",
|
||||
|
||||
@@ -38,6 +38,10 @@ export function sub2ApiHelpTargetSummary(): Record<string, unknown> {
|
||||
|
||||
export async function runPlatformInfraCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
const [target, action] = args;
|
||||
if (target === "public-edge") {
|
||||
const { runPlatformInfraPublicEdgeCommand } = await import("../platform-infra-public-edge");
|
||||
return await runPlatformInfraPublicEdgeCommand(config, args.slice(1));
|
||||
}
|
||||
if (target === "sub2rank") {
|
||||
const { runSub2RankCommand } = await import("../platform-infra-sub2rank");
|
||||
return await runSub2RankCommand(config, args.slice(1));
|
||||
|
||||
Reference in New Issue
Block a user