Merge pull request #2844 from pikasTech/feat/sub2api-nc01-host-docker
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Failed
Pipelines as Code CI / platform-infra-gitea-nc01- Failed
Pipelines as Code CI / platform-infra-temporal-nc01- Failed
Pipelines as Code CI / unidesk-host- Failed

将 NC01 Sub2API 迁移到 YAML-first host-Docker
This commit is contained in:
Lyon
2026-07-22 15:11:52 +08:00
committed by GitHub
7 changed files with 137 additions and 18 deletions
+34
View File
@@ -46,6 +46,19 @@ sources:
nc01-vpn-server-shadowsocks:
extends: templates.sources.singBoxHostClient
benchmarkRef: local-vpn-server
nc01-sub2api-docker-shadowsocks:
extends: templates.sources.singBoxHostClient
benchmarkRef: local-vpn-server
client:
configPath: /etc/unidesk/sub2api-docker-egress/sing-box.json
unitPath: /etc/systemd/system/unidesk-sub2api-docker-egress.service
serviceName: unidesk-sub2api-docker-egress
listenPort: 18089
podAccess:
enabled: false
clashApiListen: 127.0.0.1:19091
healthUrl: http://127.0.0.1:19091/connections
proxyUrl: http://127.0.0.1:18089
d601-vpn-server-shadowsocks:
extends: templates.sources.singBoxHostClient
benchmarkRef: local-vpn-server
@@ -119,6 +132,27 @@ targets:
noProxyRefs:
common: noProxy.common
extras: noProxy.nodeExtras.NC01
NC01-SUB2API-DOCKER:
extends: templates.targets.hostProxyClient
route: NC01
sourceRef: sources.nc01-sub2api-docker-shadowsocks
env:
httpProxy: http://127.0.0.1:18089
httpsProxy: http://127.0.0.1:18089
allProxy: http://127.0.0.1:18089
noProxyRefs:
common: noProxy.common
extras: noProxy.nodeExtras.NC01
files:
envFile: /etc/unidesk/sub2api-docker-egress/proxy.env
profile: /etc/profile.d/unidesk-sub2api-docker-egress.sh
apt: /etc/apt/apt.conf.d/91-unidesk-sub2api-docker-egress
dockerSystemdDropIn: /etc/systemd/system/docker.service.d/11-unidesk-sub2api-docker-egress.conf
k3sSystemdDropIn: /etc/systemd/system/k3s.service.d/11-unidesk-sub2api-docker-egress.conf
apply:
reloadSystemd: true
restartDocker: false
restartK3s: false
D601-VM:
extends: templates.targets.hostProxyClient
route: D601-VM-HOST
+41 -1
View File
@@ -240,6 +240,46 @@ targets:
- 74.48.78.17
- hyueapi.com
- .hyueapi.com
- id: NC01-DOCKER
route: NC01
namespace: platform-infra
runtimeMode: host-docker
role: active-candidate
enabled: true
databaseMode: external-active
redisMode: local-ephemeral
appReplicas: 1
redisReplicas: 1
runtime:
autoSetup: false
image:
repository: docker.io/weishaw/sub2api
tag: 0.1.162
pullPolicy: Always
dependencyImages:
redis: docker.m.daocloud.io/library/redis:8-alpine
hostDocker:
projectName: sub2api-nc01
workDir: /opt/unidesk/platform-infra/sub2api-nc01
composePath: /opt/unidesk/platform-infra/sub2api-nc01/docker-compose.yml
envPath: /opt/unidesk/platform-infra/sub2api-nc01/sub2api.env
appDataDir: /var/lib/unidesk/platform-infra/sub2api-nc01/data
appPort: 28080
redisPort: 16380
databaseHost: 82.156.23.220
databaseSslMode: require
proxyUrl: http://127.0.0.1:18089
preserveEnvKeys:
- API_KEY
noProxy:
- localhost
- 127.0.0.1
- ::1
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- hyueapi.com
- .hyueapi.com
- id: NC01
route: NC01:k3s
namespace: platform-infra
@@ -280,7 +320,7 @@ targets:
publicEdge:
enabled: true
hostname: api.hwpod.com
upstream: sub2api.platform-infra.svc.cluster.local:8080
upstream: 127.0.0.1:28080
healthPath: /health
expectedA: 152.53.229.148
publicExposure:
+11
View File
@@ -682,6 +682,13 @@ function status(server: HostProxyServer, target: HostProxyTarget): Record<string
const serverStatus = sourceUsesManagedServer(target.source) ? masterProxyServerStatus(server) : skippedMasterProxyServer(target.source, "status");
const result = runTrans(target.route, remoteStatusScript(target), 45);
const parsed = parseJson(result.stdout);
const failure = result.exitCode !== 0
? boundedFailure(result)
: parsed === null
? "remote status output is not valid JSON"
: record(parsed).ok === false
? hostProxyFailureDetail(parsed, result)
: null;
return {
ok: serverStatus.ok === true && result.exitCode === 0 && record(parsed).ok !== false,
command: "platform-infra egress-proxy host status",
@@ -692,6 +699,7 @@ function status(server: HostProxyServer, target: HostProxyTarget): Record<string
target: targetSummary(target),
remote: parsed ?? { stdoutPreview: result.stdout.slice(0, 2000) },
stderrTail: result.stderr.slice(-2000),
failure,
valuesPrinted: false,
};
}
@@ -1034,6 +1042,7 @@ ${podAccessShell}
python3 - "$health_rc" "$probe_rc" "$probe_status" "$(exists ${shQuote(target.files.envFile)})" "$(exists ${shQuote(target.files.profile)})" "$(exists ${shQuote(target.files.apt)})" "$(exists ${shQuote(target.files.dockerSystemdDropIn)})" "$(exists ${shQuote(target.files.k3sSystemdDropIn)})" "$(contains ${shQuote(target.files.envFile)} hyueapi.com)" "$(contains ${shQuote(target.files.envFile)} .hyueapi.com)" "$service_active" "$binary_sha" "$archive_sha" ${shQuote(podAccess?.enabled ? "true" : "false")} "$pod_probe_rc" "$pod_probe_status" <<'PY'
import json
import sys
from pathlib import Path
health_rc = int(sys.argv[1])
probe_rc = int(sys.argv[2])
@@ -1430,6 +1439,7 @@ function renderPlan(result: Record<string, unknown>): RenderedCliResult {
...table(["PROXY", "NO_PROXY_COUNT", "HYUEAPI", "VALUES"], [[text(env.proxyUrl), text(env.noProxyCount), "preserved", "printed=false"]]),
"",
...transHostProxyEnvTable(target),
...(result.failure === null || result.failure === undefined ? [] : ["", `FAILURE ${text(result.failure)}`]),
"",
"NEXT",
` dry-run: ${text(next.dryRun, "")}`,
@@ -1504,6 +1514,7 @@ function renderStatus(result: Record<string, unknown>): RenderedCliResult {
...table(["NO_PROXY_HYUEAPI", "NO_PROXY_WILDCARD", "VALUES"], [[text(noProxy.hyueapi), text(noProxy.wildcardHyueapi), "printed=false"]]),
"",
...transHostProxyEnvTable(target),
...(result.failure === null || result.failure === undefined ? [] : ["", `FAILURE ${text(result.failure)}`]),
"",
"Disclosure: file content, Secret values and generated proxy config are not printed.",
];
+2
View File
@@ -288,6 +288,7 @@ export function parseHostDockerConfig(value: unknown, path: string, runtimeMode:
redisPort: integerField(record, "redisPort", `${path}.hostDocker`),
databaseHost: stringField(record, "databaseHost", `${path}.hostDocker`),
databaseSslMode: stringField(record, "databaseSslMode", `${path}.hostDocker`),
proxyUrl: record.proxyUrl === undefined ? null : stringField(record, "proxyUrl", `${path}.hostDocker`),
preserveEnvKeys: record.preserveEnvKeys === undefined ? [] : stringArrayField(record, "preserveEnvKeys", `${path}.hostDocker`),
noProxy: stringArrayField(record, "noProxy", `${path}.hostDocker`),
};
@@ -298,6 +299,7 @@ export function parseHostDockerConfig(value: unknown, path: string, runtimeMode:
validatePort(hostDocker.appPort, `${path}.hostDocker.appPort`);
validatePort(hostDocker.redisPort, `${path}.hostDocker.redisPort`);
validateHostOrIp(hostDocker.databaseHost, `${path}.hostDocker.databaseHost`);
if (hostDocker.proxyUrl !== null) validateProxyUrl(hostDocker.proxyUrl, `${path}.hostDocker.proxyUrl`);
if (!/^[A-Za-z0-9_-]+$/u.test(hostDocker.databaseSslMode)) throw new Error(`${configPath}.${path}.hostDocker.databaseSslMode has an unsupported format`);
for (const key of hostDocker.preserveEnvKeys) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error(`${configPath}.${path}.hostDocker.preserveEnvKeys contains an unsupported env key`);
+1
View File
@@ -158,6 +158,7 @@ export interface Sub2ApiHostDockerConfig {
redisPort: number;
databaseHost: string;
databaseSslMode: string;
proxyUrl: string | null;
preserveEnvKeys: string[];
noProxy: string[];
}
+42 -11
View File
@@ -25,6 +25,7 @@ export function hostDockerPlanSummary(sub2api: Sub2ApiConfig, target: Sub2ApiTar
databaseHost: database.host,
databasePort: database.port,
databaseSslMode: database.sslMode,
proxyUrl: host.proxyUrl,
preserveEnvKeys: host.preserveEnvKeys,
noProxy: host.noProxy,
valuesPrinted: false,
@@ -101,13 +102,13 @@ export function hostDockerEnvFile(sub2api: Sub2ApiConfig, target: Sub2ApiTargetC
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP: String(urlAllowlist.allowInsecureHttp),
SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS: String(urlAllowlist.allowPrivateHosts),
SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS: urlAllowlist.upstreamHosts.join(","),
UPDATE_PROXY_URL: "",
HTTP_PROXY: "",
HTTPS_PROXY: "",
ALL_PROXY: "",
http_proxy: "",
https_proxy: "",
all_proxy: "",
UPDATE_PROXY_URL: host.proxyUrl ?? "",
HTTP_PROXY: host.proxyUrl ?? "",
HTTPS_PROXY: host.proxyUrl ?? "",
ALL_PROXY: host.proxyUrl ?? "",
http_proxy: host.proxyUrl ?? "",
https_proxy: host.proxyUrl ?? "",
all_proxy: host.proxyUrl ?? "",
NO_PROXY: host.noProxy.join(","),
no_proxy: host.noProxy.join(","),
GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT: "0",
@@ -127,6 +128,27 @@ export function hostDockerEnvFile(sub2api: Sub2ApiConfig, target: Sub2ApiTargetC
return `${Object.entries(values).map(([key, value]) => `${key}=${envFileValue(value)}`).join("\n")}\n`;
}
export function hostDockerConfigFile(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig, secretMaterial: ExternalActiveSecretMaterial): string {
const host = requireHostDockerConfig(target);
const database = targetDatabase(sub2api, target);
return `${JSON.stringify({
server: { host: "127.0.0.1", port: host.appPort, mode: "release" },
database: {
host: database.host,
port: database.port,
user: database.user,
password: secretMaterial.values.POSTGRES_PASSWORD,
dbname: database.dbName,
sslmode: database.sslMode,
},
redis: { host: "127.0.0.1", port: host.redisPort, password: "", db: 0, enable_tls: false },
jwt: { secret: secretMaterial.values.JWT_SECRET, expire_hour: 24 },
default: { user_concurrency: 5, user_balance: 0, api_key_prefix: "sk-", rate_multiplier: 1 },
rate_limit: { requests_per_minute: 60, burst_size: 10 },
timezone: "Asia/Shanghai",
}, null, 2)}\n`;
}
export function hostDockerDryRunScript(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
const host = requireHostDockerConfig(target);
const compose = hostDockerComposeYaml(sub2api, target);
@@ -225,8 +247,10 @@ export function hostDockerApplyScript(sub2api: Sub2ApiConfig, target: Sub2ApiTar
const host = requireHostDockerConfig(target);
const compose = hostDockerComposeYaml(sub2api, target);
const env = hostDockerEnvFile(sub2api, target, secretMaterial);
const appConfig = hostDockerConfigFile(sub2api, target, secretMaterial);
const composeB64 = Buffer.from(compose, "utf8").toString("base64");
const envB64 = Buffer.from(env, "utf8").toString("base64");
const appConfigB64 = Buffer.from(appConfig, "utf8").toString("base64");
const preserveEnvKeysB64 = Buffer.from(host.preserveEnvKeys.join("\n"), "utf8").toString("base64");
const image = targetImage(sub2api, target);
const dependencyImages = targetDependencyImages(sub2api, target);
@@ -249,10 +273,12 @@ tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
compose_tmp="$tmp/docker-compose.yml"
env_tmp="$tmp/sub2api.env"
app_config_tmp="$tmp/config.yaml"
preserve_keys="$tmp/preserve-env-keys.txt"
existing_env="$tmp/existing.env"
printf '%s' '${composeB64}' | base64 -d >"$compose_tmp"
printf '%s' '${envB64}' | base64 -d >"$env_tmp"
printf '%s' '${appConfigB64}' | base64 -d >"$app_config_tmp"
printf '%s' '${preserveEnvKeysB64}' | base64 -d >"$preserve_keys"
docker_out="$tmp/docker.out"
docker_err="$tmp/docker.err"
@@ -382,6 +408,10 @@ PY
sudo install -m 0600 -o "$current_uid" -g "$current_gid" "$env_tmp" ${shQuote(host.envPath)} >>"$install_out" 2>>"$install_err"
install_rc=$?
fi
if [ "$install_rc" -eq 0 ]; then
sudo install -m 0600 "$app_config_tmp" ${shQuote(`${host.appDataDir}/config.yaml`)} >>"$install_out" 2>>"$install_err"
install_rc=$?
fi
else
: >"$install_out"
printf '%s\\n' 'skipped because preserve env failed' >"$install_err"
@@ -465,10 +495,11 @@ health_rc=1
if [ "$up_rc" -eq 0 ]; then
deadline=$(( $(date +%s) + 180 ))
while [ "$(date +%s)" -lt "$deadline" ]; do
if curl -fsS --max-time 5 http://127.0.0.1:${host.appPort}/health >"$health_out" 2>"$health_err"; then
health_rc=0
break
fi
if curl -fsS --max-time 5 http://127.0.0.1:${host.appPort}/health >"$health_out" 2>"$health_err" \
&& grep -Eq '"status"[[:space:]]*:[[:space:]]*"ok"' "$health_out"; then
health_rc=0
break
fi
sleep 3
done
else
+6 -6
View File
@@ -68,27 +68,27 @@ export function policyChecks(sub2api: Sub2ApiConfig, yaml: string, target: Sub2A
{
name: "host-docker-no-kubernetes-manifest",
ok: yaml.trim().length === 0,
detail: "PK01 host-Docker Sub2API must not render or apply Kubernetes manifests.",
detail: `${target.id} host-Docker Sub2API must not render or apply Kubernetes manifests.`,
},
{
name: "host-docker-config-present",
ok: target.hostDocker !== null,
detail: "PK01 host-Docker Sub2API must be fully declared in config/platform-infra/sub2api.yaml.",
detail: `${target.id} host-Docker Sub2API must be fully declared in config/platform-infra/sub2api.yaml.`,
},
{
name: "host-docker-no-egress-proxy",
ok: target.egressProxy === null || !target.egressProxy.enabled,
detail: "PK01 Sub2API exits directly from PK01/api.pikapython.com and does not deploy the D601 egress proxy.",
detail: `${target.id} host-Docker Sub2API does not deploy a Kubernetes egress proxy.`,
},
{
name: "host-docker-no-sentinel",
ok: !targetHasSentinel(sub2api, target),
detail: "PK01 bare Docker deployment must not deploy the codex-pool sentinel.",
detail: `${target.id} host-Docker deployment must not deploy the Kubernetes codex-pool sentinel.`,
},
{
name: "host-docker-public-exposure-local",
ok: target.publicExposure?.enabled === true && target.publicExposure.mode === "pk01-local",
detail: "PK01 public exposure must be Caddy to local Docker, not FRP.",
ok: target.publicExposure === null || !target.publicExposure.enabled || target.publicExposure.mode === "pk01-local",
detail: `${target.id} host-Docker public exposure may be disabled for a staged cutover; when enabled it must use local Docker exposure.`,
},
];
}