fix: move sub2api runtime to PK01 host docker

This commit is contained in:
Codex
2026-06-28 14:24:08 +00:00
parent 1d07df3df1
commit d225fd1a61
18 changed files with 1088 additions and 186 deletions
@@ -27,7 +27,7 @@ import { desiredAccountNames } from "./accounts";
import { collectCodexProfiles, readCodexPoolConfig } from "./config";
import { codexPoolSentinelProbeConfigFingerprint, fingerprint } from "./config-utils";
import { apiKeyPreview, codexConsumerBaseUrl, fetchPoolApiKey, probePublicModels, validatePublicGatewayWithKey, writeLocalCodexConfig } from "./local-codex";
import { manualBindingSourcePlan, poolTarget, prepareTargetPublicExposureSecret, resolvedManualAccountProtections, secretMaterialSummary, sentinelProfileSecrets, targetPublicExposureApplyScript, targetPublicExposureSummary } from "./public-exposure";
import { manualBindingSourcePlan, poolTarget, prepareTargetPublicExposureSecret, resolvedManualAccountProtections, secretMaterialSummary, sentinelProfileSecrets, targetFrpPublicExposure, targetPublicExposureApplyScript, targetPublicExposureSummary } from "./public-exposure";
import { codexPoolConfigSummary, compactProfile, compactSentinelProbeResult, redactProfile, renderSub2ApiTempUnschedulableCredentials } from "./redaction";
import { boolField, capture, compactCapture, parseJsonOutput, runRemoteCodexPoolScript } from "./remote";
import { cleanupProbesScript, sentinelImageBuildScript, sentinelImageStatusScript, sentinelProbeScript, sentinelReportScript, syncScript, traceScript, validateScript } from "./remote-scripts";
@@ -74,7 +74,13 @@ export function codexPoolPlan(options?: DisclosureOptions): Record<string, unkno
: `${pool.manualAccounts.protected.length} manual Sub2API account(s) are protected from UniDesk-managed credentials, prune, sentinel probe, and sentinel freeze paths; only explicitly declared proxy/group bindings are reconciled.`,
},
next: ok
? { sync: `bun scripts/cli.ts platform-infra sub2api codex-pool sync${targetFlag(runtimeTarget)} --confirm` }
? runtimeTarget.runtimeMode === "host-docker"
? {
expose: `bun scripts/cli.ts platform-infra sub2api codex-pool expose${targetFlag(runtimeTarget)} --confirm`,
validate: `bun scripts/cli.ts platform-infra sub2api validate${targetFlag(runtimeTarget)}`,
note: "PK01 host-Docker target does not run the k3s codex-pool sync path.",
}
: { sync: `bun scripts/cli.ts platform-infra sub2api codex-pool sync${targetFlag(runtimeTarget)} --confirm` }
: { fix: "Ensure every discovered config.toml profile has a base_url and either auth.json OPENAI_API_KEY or the configured env_key present in this shell." },
};
}
@@ -84,6 +90,24 @@ export async function codexPoolSync(config: UniDeskConfig, options: SyncOptions)
const runtimeTarget = codexPoolRuntimeTarget(options.targetId);
const profiles = collectCodexProfiles();
const planOk = profiles.length > 0 && profiles.every((profile) => profile.ok);
if (runtimeTarget.runtimeMode === "host-docker" && options.confirm) {
return {
ok: false,
action: "platform-infra-sub2api-codex-pool-sync",
mode: "blocked-host-docker-sync-unsupported",
target: poolTarget(pool, runtimeTarget),
reason: "PK01 host-Docker target does not run the k3s codex-pool sync path; Sub2API runtime is controlled by platform-infra sub2api apply/validate.",
local: {
profileCount: profiles.length,
invalidProfiles: profiles.filter((profile) => !profile.ok).map(compactProfile),
valuesPrinted: false,
},
next: {
expose: `bun scripts/cli.ts platform-infra sub2api codex-pool expose${targetFlag(runtimeTarget)} --confirm`,
validate: `bun scripts/cli.ts platform-infra sub2api validate${targetFlag(runtimeTarget)}`,
},
};
}
if (!options.confirm || !planOk) {
return {
...codexPoolPlan(options),
@@ -454,8 +478,24 @@ export async function codexPoolExpose(config: UniDeskConfig, options: ConfirmOpt
},
};
}
if (runtimeTarget.publicExposure.mode === "pk01-local") {
const publicProbe = await probePublicModels(pool, "without-api-key", undefined, runtimeTarget);
const ok = publicProbe.httpStatus === 401;
return {
ok,
action: "platform-infra-sub2api-codex-pool-expose",
mode: "pk01-local-managed-by-sub2api-apply",
publicExposure: targetPublicExposureSummary(runtimeTarget),
publicProbe,
next: {
apply: `bun scripts/cli.ts platform-infra sub2api apply${targetFlag(runtimeTarget)} --confirm --wait`,
validate: `bun scripts/cli.ts platform-infra sub2api validate${targetFlag(runtimeTarget)}`,
configureLocal: `bun scripts/cli.ts platform-infra sub2api codex-pool configure-local${targetFlag(runtimeTarget)} --confirm`,
},
};
}
const secretMaterial = prepareTargetPublicExposureSecret(runtimeTarget);
const caddyResult = await applyPk01CaddyBlock(config, serviceName, runtimeTarget.publicExposure);
const caddyResult = await applyPk01CaddyBlock(config, serviceName, targetFrpPublicExposure(runtimeTarget));
const remoteResult = await capture(config, runtimeTarget.route, ["sh"], targetPublicExposureApplyScript(runtimeTarget, secretMaterial));
const parsed = parseJsonOutput(remoteResult.stdout);
const publicProbe = await probePublicModels(pool, "without-api-key", undefined, runtimeTarget);
@@ -284,9 +284,20 @@ export function readManualBindingSources(value: unknown, key: string): CodexPool
kind: readManualBindingKind(rawSource.kind, `${path}.kind`),
provider: readManualBindingProvider(rawSource.provider, `${path}.provider`),
description: readManualAccountReason(rawSource.description, `${path}.description`),
fixedProxy: null,
};
if (source.kind === "proxy" && source.provider !== "target-egress-proxy") {
throw new Error(`${codexPoolConfigPath}.${path}.provider must be target-egress-proxy for kind=proxy`);
if (source.kind === "proxy" && source.provider === "fixed-http-proxy") {
const fixedProxy = requiredRecordConfigField(rawSource, "fixedProxy", path);
const protocol = requiredStringConfigField(fixedProxy, "protocol", `${path}.fixedProxy`);
if (protocol !== "http") throw new Error(`${codexPoolConfigPath}.${path}.fixedProxy.protocol must be http`);
const host = requiredStringConfigField(fixedProxy, "host", `${path}.fixedProxy`);
if (!/^[A-Za-z0-9._:-]+$/u.test(host)) throw new Error(`${codexPoolConfigPath}.${path}.fixedProxy.host has an unsupported format`);
const port = integerConfigField(fixedProxy, "port", `${path}.fixedProxy`);
validatePort(port, `${path}.fixedProxy.port`);
source.fixedProxy = { protocol, host, port };
}
if (source.kind === "proxy" && source.provider !== "target-egress-proxy" && source.provider !== "fixed-http-proxy") {
throw new Error(`${codexPoolConfigPath}.${path}.provider must be target-egress-proxy or fixed-http-proxy for kind=proxy`);
}
if (source.kind === "group" && source.provider !== "pool-group") {
throw new Error(`${codexPoolConfigPath}.${path}.provider must be pool-group for kind=group`);
@@ -343,7 +354,7 @@ export function readManualBindingKind(value: unknown, key: string): CodexPoolMan
export function readManualBindingProvider(value: unknown, key: string): CodexPoolManualBindingProvider {
const text = stringValue(value);
if (text !== "target-egress-proxy" && text !== "pool-group") throw new Error(`${codexPoolConfigPath}.${key} must be target-egress-proxy or pool-group`);
if (text !== "target-egress-proxy" && text !== "fixed-http-proxy" && text !== "pool-group") throw new Error(`${codexPoolConfigPath}.${key} must be target-egress-proxy, fixed-http-proxy, or pool-group`);
return text;
}
@@ -33,6 +33,7 @@ export function poolTarget(pool = readCodexPoolConfig(), target = codexPoolRunti
id: target.id,
route: target.route,
namespace: target.namespace,
runtimeMode: target.runtimeMode,
service: serviceName,
serviceDns: target.serviceDns,
publicBaseUrl: target.publicBaseUrl,
@@ -102,6 +103,9 @@ export function resolveManualProxyBinding(binding: CodexPoolManualAccountProxyBi
if (binding.enabled && source.provider === "target-egress-proxy" && (target.egressProxy === null || target.egressProxy.enabled !== true)) {
throw new Error(`${codexPoolConfigPath}.manualAccounts.bindingSources.${source.id} requires ${sub2apiConfigPath}.targets[${target.id}].egressProxy.enabled=true`);
}
if (binding.enabled && source.provider === "fixed-http-proxy" && source.fixedProxy === null) {
throw new Error(`${codexPoolConfigPath}.manualAccounts.bindingSources.${source.id} requires fixedProxy`);
}
return {
enabled: binding.enabled,
source: source.id,
@@ -118,6 +122,7 @@ export function resolveManualProxyBinding(binding: CodexPoolManualAccountProxyBi
noProxy: target.egressProxy.noProxy,
}
: null,
fixedProxy: source.provider === "fixed-http-proxy" ? source.fixedProxy : null,
},
};
}
@@ -149,6 +154,7 @@ export function manualBindingSourcePlan(source: CodexPoolManualBindingSource): R
kind: source.kind,
provider: source.provider,
description: source.description,
fixedProxy: source.fixedProxy,
};
}
@@ -162,14 +168,17 @@ export function targetPublicExposureSummary(target: CodexPoolRuntimeTarget): Rec
id: target.id,
route: target.route,
namespace: target.namespace,
runtimeMode: target.runtimeMode,
serviceDns: target.serviceDns,
},
urls: {
publicBaseUrl: exposure.publicBaseUrl,
consumerBaseUrl: target.publicBaseUrl === null ? null : `${target.publicBaseUrl.replace(/\/+$/u, "")}/`,
},
mode: exposure.mode,
dns: exposure.dns,
frpc: {
local: exposure.local,
frpc: exposure.frpc === null ? null : {
deploymentName: exposure.frpc.deploymentName,
secretName: exposure.frpc.secretName,
secretKey: exposure.frpc.secretKey,
@@ -188,16 +197,31 @@ export function targetPublicExposureSummary(target: CodexPoolRuntimeTarget): Rec
};
}
export function targetFrpPublicExposure(target: CodexPoolRuntimeTarget): PublicServiceExposure {
const exposure = target.publicExposure;
if (exposure === null) throw new Error(`${sub2apiConfigPath}.targets[${target.id}].publicExposure is required`);
if (exposure.mode !== "frp" || exposure.frpc === null) {
throw new Error(`${sub2apiConfigPath}.targets[${target.id}].publicExposure.frpc is required when using codex-pool expose frp operations`);
}
return {
enabled: exposure.enabled,
publicBaseUrl: exposure.publicBaseUrl,
dns: exposure.dns,
frpc: exposure.frpc,
pk01: exposure.pk01,
};
}
export function prepareTargetPublicExposureSecret(target: CodexPoolRuntimeTarget): ReturnType<typeof prepareFrpcSecret> {
if (target.publicExposure === null) throw new Error(`${sub2apiConfigPath}.targets[${target.id}].publicExposure is required`);
const exposure = targetFrpPublicExposure(target);
return prepareFrpcSecret({
secretRoot: target.secretsRoot,
exposure: target.publicExposure,
exposure,
sourcePathRedactor: redactRepoPath,
parseEnvFile,
requiredEnvValue,
readTextFile: (sourcePath) => {
if (!existsSync(sourcePath)) throw new Error(`publicExposure requires ${redactRepoPath(sourcePath)} with ${target.publicExposure?.frpc.tokenSourceKey}; materialize the PK01 frps token source first`);
if (!existsSync(sourcePath)) throw new Error(`publicExposure requires ${redactRepoPath(sourcePath)} with ${exposure.frpc.tokenSourceKey}; materialize the PK01 frps token source first`);
return readTextFile(sourcePath);
},
});
@@ -215,13 +239,13 @@ export function secretMaterialSummary(secret: ReturnType<typeof prepareFrpcSecre
}
export function targetPublicExposureApplyScript(target: CodexPoolRuntimeTarget, secret: ReturnType<typeof prepareFrpcSecret>): string {
if (target.publicExposure === null) throw new Error(`${sub2apiConfigPath}.targets[${target.id}].publicExposure is required`);
const exposure = targetFrpPublicExposure(target);
const manifest = renderFrpcManifest({
id: target.id,
route: target.route,
namespace: target.namespace,
replicas: 1,
publicExposure: target.publicExposure,
publicExposure: exposure,
} satisfies PublicServiceTarget);
const manifestB64 = Buffer.from(manifest, "utf8").toString("base64");
const frpcTomlB64 = Buffer.from(secret.frpcToml, "utf8").toString("base64");
@@ -263,7 +287,7 @@ if [ "$ns_rc" -eq 0 ] && [ "$secret_rc" -eq 0 ]; then
kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f "$manifest" >"$apply_out" 2>"$apply_err"
apply_rc=$?
if [ "$apply_rc" -eq 0 ]; then
kubectl -n ${target.namespace} rollout status deployment/${target.publicExposure.frpc.deploymentName} --timeout=60s >"$rollout_out" 2>"$rollout_err"
kubectl -n ${target.namespace} rollout status deployment/${exposure.frpc.deploymentName} --timeout=60s >"$rollout_out" 2>"$rollout_err"
rollout_rc=$?
else
printf '%s\\n' 'skipped because apply failed' >"$rollout_err"
@@ -274,9 +298,9 @@ else
: >"$rollout_out"
printf '%s\\n' 'skipped because namespace or secret step failed' >"$rollout_err"
fi
kubectl -n ${target.namespace} get pods -l app.kubernetes.io/name=${target.publicExposure.frpc.deploymentName} -o json >"$pods_json" 2>"$pods_err"
kubectl -n ${target.namespace} get pods -l app.kubernetes.io/name=${exposure.frpc.deploymentName} -o json >"$pods_json" 2>"$pods_err"
pods_rc=$?
kubectl -n ${target.namespace} logs deployment/${target.publicExposure.frpc.deploymentName} --tail=80 >"$logs_out" 2>"$logs_err"
kubectl -n ${target.namespace} logs deployment/${exposure.frpc.deploymentName} --tail=80 >"$logs_out" 2>"$logs_err"
logs_rc=$?
python3 - "$tmp" "$ns_rc" "$secret_rc" "$apply_rc" "$rollout_rc" "$pods_rc" "$logs_rc" <<'PY'
import json
@@ -320,17 +344,17 @@ payload = {
"namespace": "${target.namespace}",
"source": "${sub2apiConfigPath}.targets[${target.id}].publicExposure",
"proxy": {
"name": "${target.publicExposure.frpc.proxyName}",
"remotePort": ${JSON.stringify(target.publicExposure.frpc.remotePort)},
"publicBaseUrl": "${target.publicExposure.publicBaseUrl}",
"localIP": "${target.publicExposure.frpc.localIP}",
"localPort": ${JSON.stringify(target.publicExposure.frpc.localPort)},
"name": "${exposure.frpc.proxyName}",
"remotePort": ${JSON.stringify(exposure.frpc.remotePort)},
"publicBaseUrl": "${exposure.publicBaseUrl}",
"localIP": "${exposure.frpc.localIP}",
"localPort": ${JSON.stringify(exposure.frpc.localPort)},
},
"resources": {
"secret": "${secret.secretName}",
"secretKey": "${secret.secretKey}",
"deployment": "${target.publicExposure.frpc.deploymentName}",
"image": "${target.publicExposure.frpc.image}",
"deployment": "${exposure.frpc.deploymentName}",
"image": "${exposure.frpc.image}",
},
"steps": {
"namespace": {"exitCode": ns_rc, "stdout": text("ns.out"), "stderr": text("ns.err")},
@@ -436,8 +436,32 @@ def desired_manual_proxy_payload(protection):
if not isinstance(binding, dict) or binding.get("enabled") is not True:
return None
plan = manual_binding_plan(binding, "proxy")
if plan.get("provider") != "target-egress-proxy":
if plan.get("provider") not in ("target-egress-proxy", "fixed-http-proxy"):
raise RuntimeError(f"manual account proxyBinding source {plan.get('id')} uses unsupported provider {plan.get('provider')}")
if plan.get("provider") == "fixed-http-proxy":
fixed_proxy = plan.get("fixedProxy")
if not isinstance(fixed_proxy, dict):
raise RuntimeError(f"manual account proxyBinding source {plan.get('id')} requires fixedProxy")
proxy_name = binding.get("proxyName")
protocol = fixed_proxy.get("protocol")
host = fixed_proxy.get("host")
port = fixed_proxy.get("port")
if not isinstance(proxy_name, str) or not proxy_name:
raise RuntimeError("manual account proxyBinding proxyName is missing")
if protocol != "http":
raise RuntimeError("fixed proxy protocol must be http")
if not isinstance(host, str) or not host:
raise RuntimeError("fixed proxy host is missing")
if not isinstance(port, int) or port <= 0:
raise RuntimeError("fixed proxy port is missing")
return {
"name": proxy_name,
"protocol": protocol,
"host": host,
"port": port,
"fallback_mode": "none",
"expiry_warn_days": 0,
}
target_proxy = plan.get("targetEgressProxy")
if not isinstance(target_proxy, dict):
raise RuntimeError(f"manual account proxyBinding source {plan.get('id')} requires an enabled target egressProxy")
@@ -22,7 +22,7 @@ import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "..
import { runSshCommandCapture, type SshCaptureResult } from "../ssh";
import type { Sub2ApiRuntimeConfig } from "./options";
import type { CodexPoolRuntimeTarget } from "./types";
import type { CodexPoolRuntimePublicExposure, CodexPoolRuntimeTarget } from "./types";
import { validatePort, validateProxyName } from "./config";
import { booleanValue, isRecord, normalizeBaseUrl, numberValue, stringValue, validateKubernetesName } from "./config-utils";
import { serviceName, sub2apiConfigPath } from "./types";
@@ -66,11 +66,14 @@ export function codexPoolRuntimeTarget(targetId?: string): CodexPoolRuntimeTarge
if (!isRecord(raw)) throw new Error(`${sub2apiConfigPath}.targets does not contain target ${targetId}`);
const id = stringValue(raw.id) ?? resolvedTargetId;
const route = stringValue(raw.route) ?? "";
const runtimeMode = stringValue(raw.runtimeMode) ?? "k3s";
if (runtimeMode !== "k3s" && runtimeMode !== "host-docker") throw new Error(`${sub2apiConfigPath}.targets[${id}].runtimeMode must be k3s or host-docker`);
const targetNamespace = stringValue(raw.namespace);
if (route.length === 0) throw new Error(`${sub2apiConfigPath}.targets[${id}].route is required`);
if (targetNamespace === null) throw new Error(`${sub2apiConfigPath}.targets[${id}].namespace is required`);
validateKubernetesName(targetNamespace, `${sub2apiConfigPath}.targets[${id}].namespace`, true);
const sentinelImageBuild = readTargetSentinelImageBuild(raw, id);
const sentinelEnabled = runtimeConfig.sentinelEnabledOnTargets.some((entry) => entry.toLowerCase() === id.toLowerCase());
const sentinelImageBuild = readTargetSentinelImageBuild(raw, id, sentinelEnabled);
let egressProxy: CodexPoolRuntimeTarget["egressProxy"] = null;
if (isRecord(raw.egressProxy) && raw.egressProxy.enabled === true) {
@@ -94,26 +97,33 @@ export function codexPoolRuntimeTarget(targetId?: string): CodexPoolRuntimeTarge
let publicBaseUrl: string | null = null;
const publicExposure = readTargetPublicExposure(raw, id);
if (publicExposure !== null && publicExposure.enabled) publicBaseUrl = publicExposure.publicBaseUrl;
const hostDocker = runtimeMode === "host-docker" && isRecord(raw.hostDocker) ? raw.hostDocker : null;
const hostDockerAppPort = hostDocker === null ? null : numberValue(hostDocker.appPort);
if (runtimeMode === "host-docker" && (hostDockerAppPort === null || !Number.isInteger(hostDockerAppPort) || hostDockerAppPort < 1 || hostDockerAppPort > 65535)) {
throw new Error(`${sub2apiConfigPath}.targets[${id}].hostDocker.appPort must be an integer TCP port when runtimeMode=host-docker`);
}
return {
id,
route,
namespace: targetNamespace,
runtimeMode,
serviceName,
serviceDns: `${serviceName}.${targetNamespace}.svc.cluster.local:8080`,
serviceDns: runtimeMode === "host-docker" ? `host-docker:127.0.0.1:${hostDockerAppPort}` : `${serviceName}.${targetNamespace}.svc.cluster.local:8080`,
publicBaseUrl,
publicExposure,
appSecretName: runtimeConfig.appSecretName,
secretsRoot: runtimeConfig.secretsRoot,
sentinelEnabled: runtimeConfig.sentinelEnabledOnTargets.some((entry) => entry.toLowerCase() === id.toLowerCase()),
sentinelEnabled,
sentinelImageBuild,
egressProxy,
};
}
export function readTargetSentinelImageBuild(raw: Record<string, unknown>, targetId: string): CodexPoolRuntimeTarget["sentinelImageBuild"] {
export function readTargetSentinelImageBuild(raw: Record<string, unknown>, targetId: string, required = true): CodexPoolRuntimeTarget["sentinelImageBuild"] {
const codexPool = isRecord(raw.codexPool) ? raw.codexPool : null;
const imageBuild = codexPool !== null && isRecord(codexPool.sentinelImageBuild) ? codexPool.sentinelImageBuild : null;
if (imageBuild === null && !required) return { baseImageCachePolicy: "pull", noProxy: [] };
if (imageBuild === null) throw new Error(`${sub2apiConfigPath}.targets[${targetId}].codexPool.sentinelImageBuild must be a YAML object`);
const policy = stringValue(imageBuild.baseImageCachePolicy);
if (policy !== "pull" && policy !== "local-if-present") {
@@ -125,26 +135,36 @@ export function readTargetSentinelImageBuild(raw: Record<string, unknown>, targe
};
}
export function readTargetPublicExposure(raw: Record<string, unknown>, targetId: string): PublicServiceExposure | null {
export function readTargetPublicExposure(raw: Record<string, unknown>, targetId: string): CodexPoolRuntimePublicExposure | null {
if (raw.publicExposure === undefined || raw.publicExposure === null) return null;
if (!isRecord(raw.publicExposure)) throw new Error(`${sub2apiConfigPath}.targets[${targetId}].publicExposure must be a YAML object`);
const exposure = raw.publicExposure;
const enabled = readRequiredTargetBoolean(exposure.enabled, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.enabled`);
const mode = stringValue(exposure.mode) ?? "frp";
if (mode !== "frp" && mode !== "pk01-local") throw new Error(`${sub2apiConfigPath}.targets[${targetId}].publicExposure.mode must be frp or pk01-local`);
const dns = readRequiredTargetRecord(exposure.dns, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.dns`);
const frpc = readRequiredTargetRecord(exposure.frpc, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc`);
const local = exposure.local === undefined || exposure.local === null ? null : readRequiredTargetRecord(exposure.local, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.local`);
const frpc = exposure.frpc === undefined || exposure.frpc === null ? null : readRequiredTargetRecord(exposure.frpc, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc`);
if (mode === "frp" && frpc === null) throw new Error(`${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc is required when mode=frp`);
if (mode === "pk01-local" && local === null) throw new Error(`${sub2apiConfigPath}.targets[${targetId}].publicExposure.local is required when mode=pk01-local`);
const pk01 = readRequiredTargetRecord(exposure.pk01, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.pk01`);
const publicBaseUrl = readTargetBaseUrl(exposure.publicBaseUrl, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.publicBaseUrl`, "https:");
const hostname = readRequiredTargetString(dns.hostname, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.dns.hostname`);
if (new URL(publicBaseUrl).hostname !== hostname) throw new Error(`${sub2apiConfigPath}.targets[${targetId}].publicExposure publicBaseUrl hostname must match dns.hostname`);
const config: PublicServiceExposure = {
const config: CodexPoolRuntimePublicExposure = {
enabled,
mode,
publicBaseUrl,
dns: {
hostname,
expectedA: readRequiredTargetString(dns.expectedA, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.dns.expectedA`),
resolvers: readTargetStringArray(dns.resolvers, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.dns.resolvers`),
},
frpc: {
local: local === null ? null : {
upstreamHost: readRequiredTargetString(local.upstreamHost, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.local.upstreamHost`),
upstreamPort: readTargetPort(local.upstreamPort, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.local.upstreamPort`),
},
frpc: frpc === null ? null : {
deploymentName: readRequiredTargetString(frpc.deploymentName, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.deploymentName`),
secretName: readRequiredTargetString(frpc.secretName, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.secretName`),
secretKey: readRequiredTargetString(frpc.secretKey, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.secretKey`),
@@ -165,12 +185,15 @@ export function readTargetPublicExposure(raw: Record<string, unknown>, targetId:
responseHeaderTimeoutSeconds: readPositiveTargetInteger(pk01.responseHeaderTimeoutSeconds, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.pk01.responseHeaderTimeoutSeconds`),
},
};
validateKubernetesName(config.frpc.deploymentName, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.deploymentName`, true);
validateKubernetesName(config.frpc.secretName, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.secretName`, true);
validateProxyName(config.frpc.proxyName, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.proxyName`);
validatePort(config.frpc.serverPort, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.serverPort`);
validatePort(config.frpc.remotePort, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.remotePort`);
validatePort(config.frpc.localPort, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.localPort`);
if (config.frpc !== null) {
validateKubernetesName(config.frpc.deploymentName, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.deploymentName`, true);
validateKubernetesName(config.frpc.secretName, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.secretName`, true);
validateProxyName(config.frpc.proxyName, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.proxyName`);
validatePort(config.frpc.serverPort, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.serverPort`);
validatePort(config.frpc.remotePort, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.remotePort`);
validatePort(config.frpc.localPort, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.localPort`);
}
if (config.local !== null) validatePort(config.local.upstreamPort, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.local.upstreamPort`);
return config;
}
@@ -81,10 +81,11 @@ export interface CodexPoolRuntimeTarget {
id: string;
route: string;
namespace: string;
runtimeMode: "k3s" | "host-docker";
serviceName: string;
serviceDns: string;
publicBaseUrl: string | null;
publicExposure: PublicServiceExposure | null;
publicExposure: CodexPoolRuntimePublicExposure | null;
appSecretName: string;
secretsRoot: string;
sentinelEnabled: boolean;
@@ -102,6 +103,19 @@ export interface CodexPoolRuntimeTarget {
} | null;
}
export interface CodexPoolRuntimePublicExposure {
enabled: boolean;
mode: "frp" | "pk01-local";
publicBaseUrl: string;
dns: { hostname: string; expectedA: string; resolvers: string[] };
local: {
upstreamHost: string;
upstreamPort: number;
} | null;
frpc: PublicServiceExposure["frpc"] | null;
pk01: PublicServiceExposure["pk01"];
}
export interface CodexProfile {
profile: string;
accountName: string;
@@ -177,7 +191,13 @@ export interface CodexPoolManualAccountsConfig {
export type CodexPoolManualBindingKind = "proxy" | "group";
export type CodexPoolManualBindingProvider = "target-egress-proxy" | "pool-group";
export type CodexPoolManualBindingProvider = "target-egress-proxy" | "fixed-http-proxy" | "pool-group";
export interface CodexPoolManualFixedProxyConfig {
protocol: "http";
host: string;
port: number;
}
export interface CodexPoolManualBindingSource {
id: string;
@@ -185,6 +205,7 @@ export interface CodexPoolManualBindingSource {
kind: CodexPoolManualBindingKind;
provider: CodexPoolManualBindingProvider;
description: string | null;
fixedProxy: CodexPoolManualFixedProxyConfig | null;
}
export interface CodexPoolManualBindingSourcesConfig {
+55 -32
View File
@@ -19,7 +19,8 @@ import type { ApplyOptions, DisclosureOptions, TargetOptions } from "./options";
import { applyScript, dryRunScript } from "./apply-script";
import { readSub2ApiConfig } from "./config";
import { configPath, fieldManager, manifestPath, serviceName } from "./entry";
import { imageRef, isExternalTarget, managedResourceCleanupPlan, manifest, resolveTarget, targetDependencyImages, targetHasSentinel, targetImage } from "./manifest";
import { hostDockerApplyScript, hostDockerDryRunScript, hostDockerPlanSummary, hostDockerStatusScript } from "./host-docker";
import { imageRef, isExternalTarget, isHostDockerTarget, managedResourceCleanupPlan, manifest, resolveTarget, targetDatabase, targetDependencyImages, targetHasSentinel, targetImage } from "./manifest";
import { applyPk01PublicExposure } from "./pk01-public-exposure";
import { policyChecks } from "./policy";
import { prepareEgressProxySecret, prepareExternalActiveSecret, preparePublicExposureSecret } from "./secrets-and-egress";
@@ -29,21 +30,25 @@ import { boolField } from "./utils";
export function publicExposureSummary(exposure: Sub2ApiPublicExposureConfig): Record<string, unknown> {
return {
enabled: exposure.enabled,
mode: exposure.mode,
publicBaseUrl: exposure.publicBaseUrl,
dns: exposure.dns,
frpc: {
deploymentName: exposure.frpc.deploymentName,
secretName: exposure.frpc.secretName,
image: exposure.frpc.image,
serverAddr: exposure.frpc.serverAddr,
serverPort: exposure.frpc.serverPort,
proxyName: exposure.frpc.proxyName,
remotePort: exposure.frpc.remotePort,
localIP: exposure.frpc.localIP,
localPort: exposure.frpc.localPort,
tokenSourceRef: exposure.frpc.tokenSourceRef,
valuesPrinted: false,
},
local: exposure.local,
frpc: exposure.frpc === null
? null
: {
deploymentName: exposure.frpc.deploymentName,
secretName: exposure.frpc.secretName,
image: exposure.frpc.image,
serverAddr: exposure.frpc.serverAddr,
serverPort: exposure.frpc.serverPort,
proxyName: exposure.frpc.proxyName,
remotePort: exposure.frpc.remotePort,
localIP: exposure.frpc.localIP,
localPort: exposure.frpc.localPort,
tokenSourceRef: exposure.frpc.tokenSourceRef,
valuesPrinted: false,
},
pk01: {
route: exposure.pk01.route,
mode: "caddy-edge",
@@ -92,6 +97,8 @@ export function plan(options: TargetOptions): Record<string, unknown> {
const target = resolveTarget(sub2api, options.targetId);
const yaml = manifest(sub2api, target);
const policy = policyChecks(sub2api, yaml, target);
const database = targetDatabase(sub2api, target);
const hostDocker = isHostDockerTarget(target) ? hostDockerPlanSummary(sub2api, target) : null;
return {
ok: policy.every((check) => check.ok),
action: "platform-infra-sub2api-plan",
@@ -99,11 +106,12 @@ export function plan(options: TargetOptions): Record<string, unknown> {
id: target.id,
route: target.route,
namespace: target.namespace,
runtimeMode: target.runtimeMode,
role: target.role,
manifestPath,
configPath,
fieldManager,
serviceDns: `${serviceName}.${target.namespace}.svc.cluster.local:8080`,
serviceDns: isHostDockerTarget(target) ? `host-docker:127.0.0.1:${target.hostDocker?.appPort ?? 0}` : `${serviceName}.${target.namespace}.svc.cluster.local:8080`,
},
config: {
path: configPath,
@@ -115,10 +123,12 @@ export function plan(options: TargetOptions): Record<string, unknown> {
dependencyImages: targetDependencyImages(sub2api, target),
security: sub2api.security,
target: {
runtimeMode: target.runtimeMode,
databaseMode: target.databaseMode,
redisMode: target.redisMode,
appReplicas: target.appReplicas,
redisReplicas: target.redisReplicas,
hostDocker,
publicExposure: target.publicExposure === null ? null : publicExposureSummary(target.publicExposure),
egressProxy: target.egressProxy === null ? null : egressProxySummary(target.egressProxy),
},
@@ -128,12 +138,12 @@ export function plan(options: TargetOptions): Record<string, unknown> {
sourceKeys: sub2api.runtime.database.sourceKeys,
secretName: sub2api.runtime.database.secretName,
passwordKey: sub2api.runtime.database.passwordKey,
host: sub2api.runtime.database.host,
port: sub2api.runtime.database.port,
user: sub2api.runtime.database.user,
dbName: sub2api.runtime.database.dbName,
sslMode: sub2api.runtime.database.sslMode,
pendingAllowed: sub2api.runtime.database.pendingAllowed,
host: database.host,
port: database.port,
user: database.user,
dbName: database.dbName,
sslMode: database.sslMode,
pendingAllowed: database.pendingAllowed,
} : null,
},
decision: {
@@ -141,20 +151,26 @@ export function plan(options: TargetOptions): Record<string, unknown> {
namespace: target.namespace,
reason: target.databaseMode === "external-pending"
? `${target.id} is a standby Sub2API platform-infra target prepared through YAML; it stays scaled to zero until this target is promoted in YAML.`
: isHostDockerTarget(target)
? `${target.id} is activated as a PK01 host-Docker Sub2API target against PK01 local PostgreSQL without k3s or sentinel.`
: target.databaseMode === "external-active"
? `${target.id} is activated as a platform-infra Sub2API target against the external PK01 PostgreSQL runtime while keeping app state outside the k3s node.`
: `${target.id} is a bundled active Sub2API platform-infra target.`,
exposure: target.publicExposure?.enabled
? `Public HTTPS ${target.publicExposure.publicBaseUrl} through PK01 Caddy and ${target.id} frpc; no master server forwarding and no Kubernetes Ingress/NodePort/LoadBalancer.`
? isHostDockerTarget(target)
? `Public HTTPS ${target.publicExposure.publicBaseUrl} through PK01 Caddy to PK01 local Docker; no D601 egress, no k3s Ingress/NodePort/LoadBalancer.`
: `Public HTTPS ${target.publicExposure.publicBaseUrl} through PK01 Caddy and ${target.id} frpc; no master server forwarding and no Kubernetes Ingress/NodePort/LoadBalancer.`
: "ClusterIP only; no public ingress or node-level exposure.",
resourcePolicy: "No Kubernetes CPU/memory requests or limits, matching issue #220.",
resourcePolicy: isHostDockerTarget(target) ? "PK01 host-Docker compose is controlled by YAML; no Kubernetes resources are rendered." : "No Kubernetes CPU/memory requests or limits, matching issue #220.",
imageVersionControl: "Sub2API image repository/tag/pullPolicy are controlled by config/platform-infra/sub2api.yaml in the UniDesk repository.",
urlAllowlistControl: "Sub2API upstream URL validation options are controlled by config/platform-infra/sub2api.yaml and rendered to SECURITY_URL_ALLOWLIST_* env vars.",
networkPolicy: "NetworkPolicy/allow-all is rendered with the deployment so kube-router cannot silently default-deny Sub2API cross-pod traffic.",
networkPolicy: isHostDockerTarget(target) ? "Not applicable for PK01 host-Docker deployment." : "NetworkPolicy/allow-all is rendered with the deployment so kube-router cannot silently default-deny Sub2API cross-pod traffic.",
publicExposure: target.publicExposure?.enabled
? {
mode: "pk01-caddy-frp-direct",
dataPath: `client -> PK01 Caddy -> PK01 frps remotePort -> ${target.id} frpc -> Sub2API`,
mode: target.publicExposure.mode === "pk01-local" ? "pk01-caddy-local-docker" : "pk01-caddy-frp-direct",
dataPath: target.publicExposure.mode === "pk01-local"
? `client -> PK01 Caddy -> ${target.publicExposure.local?.upstreamHost}:${target.publicExposure.local?.upstreamPort} -> PK01 Docker Sub2API`
: `client -> PK01 Caddy -> PK01 frps remotePort -> ${target.id} frpc -> Sub2API`,
pikanodeRole: `pikapython.com upstream only; ${target.publicExposure.dns.hostname} does not pass through pikanode Express`,
publicBaseUrl: target.publicExposure.publicBaseUrl,
hostname: target.publicExposure.dns.hostname,
@@ -175,7 +191,7 @@ export function plan(options: TargetOptions): Record<string, unknown> {
: null,
dataStores: isExternalTarget(target)
? [
target.databaseMode === "external-active" ? "External PostgreSQL active from platform-db/PK01" : "External PostgreSQL pending from platform-db/Pika01",
isHostDockerTarget(target) ? `PK01 local PostgreSQL through ${database.host}:${database.port}` : target.databaseMode === "external-active" ? "External PostgreSQL active from platform-db/PK01" : "External PostgreSQL pending from platform-db/Pika01",
target.redisReplicas === 0 ? `${target.id} local Redis 8 ephemeral cache, scaled to zero until activation` : `${target.id} local Redis 8 ephemeral cache`,
]
: ["PostgreSQL 18", "Redis 8"],
@@ -226,7 +242,7 @@ export async function apply(config: UniDeskConfig, options: ApplyOptions): Promi
const job = startJob(
`platform_infra_sub2api_apply_${target.id.toLowerCase()}`,
["bun", "scripts/cli.ts", "platform-infra", "sub2api", "apply", "--target", target.id, "--confirm", "--wait"],
`Apply ${target.id} k3s platform-infra Sub2API manifests through the controlled UniDesk CLI`,
`Apply ${target.id} ${isHostDockerTarget(target) ? "host-Docker" : "k3s"} platform-infra Sub2API desired state through the controlled UniDesk CLI`,
);
return {
ok: true,
@@ -247,7 +263,7 @@ export async function apply(config: UniDeskConfig, options: ApplyOptions): Promi
};
}
if (options.dryRun) {
const result = await capture(config, target.route, ["sh"], dryRunScript(yaml, target));
const result = await capture(config, target.route, ["sh"], isHostDockerTarget(target) ? hostDockerDryRunScript(sub2api, target) : dryRunScript(yaml, target));
const parsed = parseJsonOutput(result.stdout);
return {
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
@@ -265,9 +281,16 @@ export async function apply(config: UniDeskConfig, options: ApplyOptions): Promi
const secretMaterial = target.databaseMode === "external-active" ? prepareExternalActiveSecret(sub2api) : null;
const publicExposureSecretMaterial = preparePublicExposureSecret(sub2api, target);
const egressProxySecretMaterial = prepareEgressProxySecret(sub2api, target);
const result = await capture(config, target.route, ["sh"], applyScript(sub2api, yaml, target, secretMaterial, publicExposureSecretMaterial, egressProxySecretMaterial));
const result = await capture(
config,
target.route,
["sh"],
isHostDockerTarget(target)
? hostDockerApplyScript(sub2api, target, secretMaterial)
: applyScript(sub2api, yaml, target, secretMaterial, publicExposureSecretMaterial, egressProxySecretMaterial),
);
const parsed = parseJsonOutput(result.stdout);
const pk01Exposure = publicExposureSecretMaterial === null ? null : await applyPk01PublicExposure(config, target);
const pk01Exposure = target.publicExposure?.enabled === true ? await applyPk01PublicExposure(config, target) : null;
return {
ok: result.exitCode === 0 && boolField(parsed, "ok", false) && (pk01Exposure === null || pk01Exposure.ok === true),
action: "platform-infra-sub2api-apply",
@@ -290,7 +313,7 @@ export async function apply(config: UniDeskConfig, options: ApplyOptions): Promi
export async function status(config: UniDeskConfig, options: DisclosureOptions): Promise<Record<string, unknown>> {
const sub2api = readSub2ApiConfig();
const target = resolveTarget(sub2api, options.targetId);
const result = await capture(config, target.route, ["sh"], statusScript(sub2api, target));
const result = await capture(config, target.route, ["sh"], isHostDockerTarget(target) ? hostDockerStatusScript(sub2api, target) : statusScript(sub2api, target));
const parsed = parseJsonOutput(result.stdout);
if (options.raw) {
return {
+10 -1
View File
@@ -24,7 +24,7 @@ export function renderPk01Caddyfile(target: Sub2ApiTargetConfig, exposure: Sub2A
sub2apiCaddyManagedMarker(target),
renderSimpleReverseProxyCaddySiteBlock({
hostname: exposure.dns.hostname,
upstream: `127.0.0.1:${exposure.frpc.remotePort}`,
upstream: publicExposureUpstream(target, exposure),
responseHeaderTimeoutSeconds: exposure.pk01.responseHeaderTimeoutSeconds,
}),
);
@@ -43,6 +43,15 @@ ${apiBlock.trim()}
`;
}
export function publicExposureUpstream(target: Sub2ApiTargetConfig, exposure: Sub2ApiPublicExposureConfig): string {
if (exposure.mode === "pk01-local") {
if (exposure.local === null) throw new Error(`publicExposure.local is required for target ${target.id} when mode=pk01-local`);
return `${exposure.local.upstreamHost}:${exposure.local.upstreamPort}`;
}
if (exposure.frpc === null) throw new Error(`publicExposure.frpc is required for target ${target.id} when mode=frp`);
return `127.0.0.1:${exposure.frpc.remotePort}`;
}
export function renderPk01CaddyService(exposure: Sub2ApiPublicExposureConfig): string {
return `[Unit]
Description=UniDesk PK01 Caddy edge for PikaPython and Sub2API
+69 -16
View File
@@ -15,7 +15,7 @@ import { capture, compactCapture, parseJsonOutput, prepareFrpcSecret, shQuote }
import { yamlBooleanField, yamlFieldLabel, yamlIntegerField } from "../platform-infra-ops-library";
import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets";
import type { Sub2ApiConfig, Sub2ApiDefaults, Sub2ApiEgressProxyConfig, Sub2ApiMasterShadowsocksConfig, Sub2ApiPublicExposureConfig, Sub2ApiTargetConfig } from "./entry";
import type { Sub2ApiConfig, Sub2ApiDefaults, Sub2ApiEgressProxyConfig, Sub2ApiHostDockerConfig, Sub2ApiMasterShadowsocksConfig, Sub2ApiPublicExposureConfig, Sub2ApiTargetConfig } from "./entry";
import { secretRoot } from "./apply-script";
import { configPath } from "./entry";
import { normalizePublicBaseUrl, validateHostOrIp, validateHostname, validateKubernetesResourceName, validatePort, validateProxyName, validateProxyUrl } from "./manifest";
@@ -162,6 +162,7 @@ export function parseTargets(root: Record<string, unknown>, defaultTargetId: str
const id = stringField(record, "id", path);
const route = stringField(record, "route", path);
const targetNamespace = stringField(record, "namespace", path);
const runtimeMode = record.runtimeMode === undefined ? "k3s" : enumField(record, "runtimeMode", path, ["k3s", "host-docker"] as const);
const role = stringField(record, "role", path);
const enabled = booleanField(record, "enabled", path);
const databaseMode = enumField(record, "databaseMode", path, ["bundled", "external-pending", "external-active"] as const);
@@ -170,6 +171,7 @@ export function parseTargets(root: Record<string, unknown>, defaultTargetId: str
const redisReplicas = integerField(record, "redisReplicas", path);
const image = targetImageOverride(record, path);
const dependencyImages = targetDependencyImageOverride(record, path);
const hostDocker = parseHostDockerConfig(record.hostDocker, path, runtimeMode);
const publicExposure = parsePublicExposureConfig(record.publicExposure, path);
const egressProxy = parseEgressProxyConfig(record.egressProxy, path);
if (!/^[A-Za-z0-9._-]+$/u.test(id)) throw new Error(`${configPath}.${path}.id must be a simple target id`);
@@ -177,7 +179,10 @@ export function parseTargets(root: Record<string, unknown>, defaultTargetId: str
if (!isKubernetesName(targetNamespace)) throw new Error(`${configPath}.${path}.namespace must be a Kubernetes namespace name`);
if (appReplicas < 0) throw new Error(`${configPath}.${path}.appReplicas must be >= 0`);
if (redisReplicas < 0) throw new Error(`${configPath}.${path}.redisReplicas must be >= 0`);
return { id, route, namespace: targetNamespace, role, enabled, databaseMode, redisMode, appReplicas, redisReplicas, image, dependencyImages, publicExposure, egressProxy };
if (runtimeMode === "host-docker" && databaseMode !== "external-active") throw new Error(`${configPath}.${path}.databaseMode must be external-active for runtimeMode=host-docker`);
if (runtimeMode === "host-docker" && egressProxy?.enabled === true) throw new Error(`${configPath}.${path}.egressProxy must be disabled or omitted for runtimeMode=host-docker`);
if (runtimeMode === "host-docker" && publicExposure?.enabled === true && publicExposure.mode !== "pk01-local") throw new Error(`${configPath}.${path}.publicExposure.mode must be pk01-local for runtimeMode=host-docker`);
return { id, route, namespace: targetNamespace, runtimeMode, role, enabled, databaseMode, redisMode, appReplicas, redisReplicas, image, dependencyImages, hostDocker, publicExposure, egressProxy };
});
const ids = new Set<string>();
for (const target of targets) {
@@ -188,6 +193,37 @@ export function parseTargets(root: Record<string, unknown>, defaultTargetId: str
return targets;
}
export function parseHostDockerConfig(value: unknown, path: string, runtimeMode: Sub2ApiTargetConfig["runtimeMode"]): Sub2ApiHostDockerConfig | null {
if (value === undefined || value === null) {
if (runtimeMode === "host-docker") throw new Error(`${configPath}.${path}.hostDocker is required when runtimeMode=host-docker`);
return null;
}
if (runtimeMode !== "host-docker") throw new Error(`${configPath}.${path}.hostDocker is only supported when runtimeMode=host-docker`);
if (typeof value !== "object" || Array.isArray(value)) throw new Error(`${configPath}.${path}.hostDocker must be an object`);
const record = value as Record<string, unknown>;
const hostDocker: Sub2ApiHostDockerConfig = {
projectName: stringField(record, "projectName", `${path}.hostDocker`),
workDir: stringField(record, "workDir", `${path}.hostDocker`),
composePath: stringField(record, "composePath", `${path}.hostDocker`),
envPath: stringField(record, "envPath", `${path}.hostDocker`),
appDataDir: stringField(record, "appDataDir", `${path}.hostDocker`),
appPort: integerField(record, "appPort", `${path}.hostDocker`),
redisPort: integerField(record, "redisPort", `${path}.hostDocker`),
databaseHost: stringField(record, "databaseHost", `${path}.hostDocker`),
databaseSslMode: stringField(record, "databaseSslMode", `${path}.hostDocker`),
noProxy: stringArrayField(record, "noProxy", `${path}.hostDocker`),
};
validateProxyName(hostDocker.projectName, `${path}.hostDocker.projectName`);
for (const key of ["workDir", "composePath", "envPath", "appDataDir"] as const) {
if (!hostDocker[key].startsWith("/")) throw new Error(`${configPath}.${path}.hostDocker.${key} must be absolute`);
}
validatePort(hostDocker.appPort, `${path}.hostDocker.appPort`);
validatePort(hostDocker.redisPort, `${path}.hostDocker.redisPort`);
validateHostOrIp(hostDocker.databaseHost, `${path}.hostDocker.databaseHost`);
if (!/^[A-Za-z0-9_-]+$/u.test(hostDocker.databaseSslMode)) throw new Error(`${configPath}.${path}.hostDocker.databaseSslMode has an unsupported format`);
return hostDocker;
}
export function targetImageOverride(record: Record<string, unknown>, path: string): Partial<Sub2ApiConfig["image"]> {
if (record.image === undefined) return {};
const image = objectField(record, "image", path);
@@ -230,22 +266,31 @@ export function parsePublicExposureConfig(value: unknown, path: string): Sub2Api
if (typeof value !== "object" || Array.isArray(value)) throw new Error(`${configPath}.${path}.publicExposure must be an object`);
const record = value as Record<string, unknown>;
const enabled = booleanField(record, "enabled", `${path}.publicExposure`);
const mode = record.mode === undefined ? "frp" : enumField(record, "mode", `${path}.publicExposure`, ["frp", "pk01-local"] as const);
const publicBaseUrl = stringField(record, "publicBaseUrl", `${path}.publicExposure`);
const dnsRaw = objectField(record, "dns", `${path}.publicExposure`);
const frpcRaw = objectField(record, "frpc", `${path}.publicExposure`);
const localRaw = record.local === undefined || record.local === null ? null : objectField(record, "local", `${path}.publicExposure`);
const frpcRaw = record.frpc === undefined || record.frpc === null ? null : objectField(record, "frpc", `${path}.publicExposure`);
const pk01Raw = objectField(record, "pk01", `${path}.publicExposure`);
const hostname = stringField(dnsRaw, "hostname", `${path}.publicExposure.dns`);
const expectedA = stringField(dnsRaw, "expectedA", `${path}.publicExposure.dns`);
const resolvers = stringArrayField(dnsRaw, "resolvers", `${path}.publicExposure.dns`);
if (mode === "frp" && frpcRaw === null) throw new Error(`${configPath}.${path}.publicExposure.frpc is required when mode=frp`);
if (mode === "pk01-local" && localRaw === null) throw new Error(`${configPath}.${path}.publicExposure.local is required when mode=pk01-local`);
const exposure: Sub2ApiPublicExposureConfig = {
enabled,
mode,
publicBaseUrl: normalizePublicBaseUrl(publicBaseUrl, `${path}.publicExposure.publicBaseUrl`),
dns: {
hostname,
expectedA,
resolvers,
},
frpc: {
local: localRaw === null ? null : {
upstreamHost: stringField(localRaw, "upstreamHost", `${path}.publicExposure.local`),
upstreamPort: integerField(localRaw, "upstreamPort", `${path}.publicExposure.local`),
},
frpc: frpcRaw === null ? null : {
deploymentName: stringField(frpcRaw, "deploymentName", `${path}.publicExposure.frpc`),
secretName: stringField(frpcRaw, "secretName", `${path}.publicExposure.frpc`),
secretKey: stringField(frpcRaw, "secretKey", `${path}.publicExposure.frpc`),
@@ -475,18 +520,26 @@ export function validatePublicExposureConfig(config: Sub2ApiPublicExposureConfig
for (const resolver of config.dns.resolvers) {
if (!/^[0-9.]+$/u.test(resolver)) throw new Error(`${configPath}.${path}.publicExposure.dns.resolvers entries must be IPv4 resolvers`);
}
validateKubernetesResourceName(config.frpc.deploymentName, `${path}.publicExposure.frpc.deploymentName`);
validateKubernetesResourceName(config.frpc.secretName, `${path}.publicExposure.frpc.secretName`);
if (config.frpc.secretKey !== "frpc.toml") throw new Error(`${configPath}.${path}.publicExposure.frpc.secretKey must be frpc.toml`);
if (!isImageReference(config.frpc.image)) throw new Error(`${configPath}.${path}.publicExposure.frpc.image has an unsupported format`);
validateHostOrIp(config.frpc.serverAddr, `${path}.publicExposure.frpc.serverAddr`);
validatePort(config.frpc.serverPort, `${path}.publicExposure.frpc.serverPort`);
validateProxyName(config.frpc.proxyName, `${path}.publicExposure.frpc.proxyName`);
validatePort(config.frpc.remotePort, `${path}.publicExposure.frpc.remotePort`);
validateHostOrIp(config.frpc.localIP, `${path}.publicExposure.frpc.localIP`);
validatePort(config.frpc.localPort, `${path}.publicExposure.frpc.localPort`);
if (!/^[A-Za-z0-9_./-]+$/u.test(config.frpc.tokenSourceRef)) throw new Error(`${configPath}.${path}.publicExposure.frpc.tokenSourceRef has an unsupported format`);
if (!/^[A-Z0-9_]+$/u.test(config.frpc.tokenSourceKey)) throw new Error(`${configPath}.${path}.publicExposure.frpc.tokenSourceKey must be an env key`);
if (config.mode === "frp") {
if (config.frpc === null) throw new Error(`${configPath}.${path}.publicExposure.frpc is required when mode=frp`);
validateKubernetesResourceName(config.frpc.deploymentName, `${path}.publicExposure.frpc.deploymentName`);
validateKubernetesResourceName(config.frpc.secretName, `${path}.publicExposure.frpc.secretName`);
if (config.frpc.secretKey !== "frpc.toml") throw new Error(`${configPath}.${path}.publicExposure.frpc.secretKey must be frpc.toml`);
if (!isImageReference(config.frpc.image)) throw new Error(`${configPath}.${path}.publicExposure.frpc.image has an unsupported format`);
validateHostOrIp(config.frpc.serverAddr, `${path}.publicExposure.frpc.serverAddr`);
validatePort(config.frpc.serverPort, `${path}.publicExposure.frpc.serverPort`);
validateProxyName(config.frpc.proxyName, `${path}.publicExposure.frpc.proxyName`);
validatePort(config.frpc.remotePort, `${path}.publicExposure.frpc.remotePort`);
validateHostOrIp(config.frpc.localIP, `${path}.publicExposure.frpc.localIP`);
validatePort(config.frpc.localPort, `${path}.publicExposure.frpc.localPort`);
if (!/^[A-Za-z0-9_./-]+$/u.test(config.frpc.tokenSourceRef)) throw new Error(`${configPath}.${path}.publicExposure.frpc.tokenSourceRef has an unsupported format`);
if (!/^[A-Z0-9_]+$/u.test(config.frpc.tokenSourceKey)) throw new Error(`${configPath}.${path}.publicExposure.frpc.tokenSourceKey must be an env key`);
}
if (config.mode === "pk01-local") {
if (config.local === null) throw new Error(`${configPath}.${path}.publicExposure.local is required when mode=pk01-local`);
validateHostOrIp(config.local.upstreamHost, `${path}.publicExposure.local.upstreamHost`);
validatePort(config.local.upstreamPort, `${path}.publicExposure.local.upstreamPort`);
}
if (!/^[A-Za-z0-9:_./-]+$/u.test(config.pk01.route)) throw new Error(`${configPath}.${path}.publicExposure.pk01.route has an unsupported format`);
if (!config.pk01.caddyBinaryPath.startsWith("/")) throw new Error(`${configPath}.${path}.publicExposure.pk01.caddyBinaryPath must be absolute`);
if (!/^https:\/\//u.test(config.pk01.caddyDownloadUrl)) throw new Error(`${configPath}.${path}.publicExposure.pk01.caddyDownloadUrl must use https://`);
+25 -2
View File
@@ -30,7 +30,8 @@ export const codexPoolConfigPath = rootPath("config", "platform-infra", "sub2api
export const legacySub2apiCaddyManagedMarker = "sub2api";
export function sub2apiCaddyManagedMarker(target: Sub2ApiTargetConfig): string {
return target.id.toUpperCase() === "D601" ? legacySub2apiCaddyManagedMarker : `${legacySub2apiCaddyManagedMarker}-${target.id.toLowerCase()}`;
const id = target.id.toUpperCase();
return id === "D601" || id === "PK01" ? legacySub2apiCaddyManagedMarker : `${legacySub2apiCaddyManagedMarker}-${target.id.toLowerCase()}`;
}
export const requiredSecretKeys = ["POSTGRES_PASSWORD", "ADMIN_PASSWORD", "JWT_SECRET", "TOTP_ENCRYPTION_KEY"] as const;
@@ -39,6 +40,8 @@ export type DatabaseMode = "bundled" | "external-pending" | "external-active";
export type RedisMode = "bundled-persistent" | "local-ephemeral";
export type RuntimeMode = "k3s" | "host-docker";
export interface Sub2ApiConfig {
version: number;
kind: string;
@@ -109,6 +112,7 @@ export interface Sub2ApiTargetConfig {
id: string;
route: string;
namespace: string;
runtimeMode: RuntimeMode;
role: string;
enabled: boolean;
databaseMode: DatabaseMode;
@@ -117,18 +121,37 @@ export interface Sub2ApiTargetConfig {
redisReplicas: number;
image: Partial<Sub2ApiConfig["image"]>;
dependencyImages: Partial<Sub2ApiConfig["dependencyImages"]>;
hostDocker: Sub2ApiHostDockerConfig | null;
publicExposure: Sub2ApiPublicExposureConfig | null;
egressProxy: Sub2ApiEgressProxyConfig | null;
}
export interface Sub2ApiHostDockerConfig {
projectName: string;
workDir: string;
composePath: string;
envPath: string;
appDataDir: string;
appPort: number;
redisPort: number;
databaseHost: string;
databaseSslMode: string;
noProxy: string[];
}
export interface Sub2ApiPublicExposureConfig {
enabled: boolean;
mode: "frp" | "pk01-local";
publicBaseUrl: string;
dns: {
hostname: string;
expectedA: string;
resolvers: string[];
};
local: {
upstreamHost: string;
upstreamPort: number;
} | null;
frpc: {
deploymentName: string;
secretName: string;
@@ -142,7 +165,7 @@ export interface Sub2ApiPublicExposureConfig {
localPort: number;
tokenSourceRef: string;
tokenSourceKey: string;
};
} | null;
pk01: {
route: string;
caddyBinaryPath: string;
+593
View File
@@ -0,0 +1,593 @@
import { shQuote } from "../platform-infra-public-service";
import type { ExternalActiveSecretMaterial, Sub2ApiConfig, Sub2ApiHostDockerConfig, Sub2ApiTargetConfig } from "./entry";
import { requiredSecretKeys } from "./entry";
import { imageRef, targetDatabase, targetDependencyImages, targetImage } from "./manifest";
export function requireHostDockerConfig(target: Sub2ApiTargetConfig): Sub2ApiHostDockerConfig {
if (target.hostDocker === null) throw new Error(`target ${target.id} requires hostDocker config when runtimeMode=host-docker`);
return target.hostDocker;
}
export function hostDockerPlanSummary(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): Record<string, unknown> {
const host = requireHostDockerConfig(target);
const database = targetDatabase(sub2api, target);
return {
runtimeMode: "host-docker",
route: target.route,
projectName: host.projectName,
workDir: host.workDir,
composePath: host.composePath,
envPath: host.envPath,
appDataDir: host.appDataDir,
appPort: host.appPort,
redisPort: host.redisPort,
databaseHost: database.host,
databasePort: database.port,
databaseSslMode: database.sslMode,
noProxy: host.noProxy,
valuesPrinted: false,
};
}
export function hostDockerComposeYaml(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
const host = requireHostDockerConfig(target);
const dependencyImages = targetDependencyImages(sub2api, target);
return `services:
redis:
image: ${yamlString(dependencyImages.redis)}
container_name: ${yamlString(`${host.projectName}-redis`)}
network_mode: host
restart: unless-stopped
command:
- redis-server
- --save
- ""
- --appendonly
- "no"
- --bind
- "127.0.0.1"
- --port
- ${yamlString(String(host.redisPort))}
app:
image: ${yamlString(imageRef(sub2api, target))}
container_name: ${yamlString(`${host.projectName}-app`)}
network_mode: host
restart: unless-stopped
env_file:
- ${yamlString(host.envPath)}
volumes:
- ${yamlString(`${host.appDataDir}:/app/data`)}
depends_on:
- redis
`;
}
export function hostDockerEnvFile(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig, secretMaterial: ExternalActiveSecretMaterial): string {
const host = requireHostDockerConfig(target);
const database = targetDatabase(sub2api, target);
const urlAllowlist = sub2api.security.urlAllowlist;
const values: Record<string, string> = {
AUTO_SETUP: "true",
SERVER_HOST: "127.0.0.1",
SERVER_PORT: String(host.appPort),
SERVER_MODE: "release",
RUN_MODE: "standard",
DATABASE_HOST: database.host,
DATABASE_PORT: String(database.port),
DATABASE_USER: database.user,
DATABASE_DBNAME: database.dbName,
DATABASE_SSLMODE: database.sslMode,
DATABASE_PASSWORD: secretMaterial.values.POSTGRES_PASSWORD,
DATABASE_MAX_OPEN_CONNS: "10",
DATABASE_MAX_IDLE_CONNS: "2",
DATABASE_CONN_MAX_LIFETIME_MINUTES: "30",
DATABASE_CONN_MAX_IDLE_TIME_MINUTES: "5",
REDIS_HOST: "127.0.0.1",
REDIS_PORT: String(host.redisPort),
REDIS_PASSWORD: "",
REDIS_DB: "0",
REDIS_POOL_SIZE: "32",
REDIS_MIN_IDLE_CONNS: "2",
REDIS_ENABLE_TLS: "false",
ADMIN_EMAIL: "admin@sub2api.platform-infra.local",
ADMIN_PASSWORD: secretMaterial.values.ADMIN_PASSWORD,
JWT_SECRET: secretMaterial.values.JWT_SECRET,
TOTP_ENCRYPTION_KEY: secretMaterial.values.TOTP_ENCRYPTION_KEY,
JWT_EXPIRE_HOUR: "24",
TZ: "Asia/Shanghai",
SECURITY_URL_ALLOWLIST_ENABLED: String(urlAllowlist.enabled),
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: "",
NO_PROXY: host.noProxy.join(","),
no_proxy: host.noProxy.join(","),
GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT: "0",
GATEWAY_OPENAI_HTTP2_ENABLED: "true",
GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1: "true",
GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD: "2",
GATEWAY_OPENAI_HTTP2_FALLBACK_WINDOW_SECONDS: "60",
GATEWAY_OPENAI_HTTP2_FALLBACK_TTL_SECONDS: "600",
GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT: "900",
GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL: "10",
GATEWAY_IMAGE_CONCURRENCY_ENABLED: "false",
GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS: "0",
GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE: "reject",
GATEWAY_IMAGE_CONCURRENCY_WAIT_TIMEOUT_SECONDS: "30",
GATEWAY_IMAGE_CONCURRENCY_MAX_WAITING_REQUESTS: "100",
};
return `${Object.entries(values).map(([key, value]) => `${key}=${envFileValue(value)}`).join("\n")}\n`;
}
export function hostDockerDryRunScript(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
const host = requireHostDockerConfig(target);
const compose = hostDockerComposeYaml(sub2api, target);
const composeB64 = Buffer.from(compose, "utf8").toString("base64");
return `
set -u
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
compose="$tmp/docker-compose.yml"
printf '%s' '${composeB64}' | base64 -d >"$compose"
docker_out="$tmp/docker.out"
docker_err="$tmp/docker.err"
compose_out="$tmp/compose.out"
compose_err="$tmp/compose.err"
ports_out="$tmp/ports.out"
ports_err="$tmp/ports.err"
docker_bin=""
if docker ps >/dev/null 2>&1; then
docker_bin="docker"
elif sudo docker ps >/dev/null 2>&1; then
docker_bin="sudo docker"
fi
if [ -n "$docker_bin" ]; then
$docker_bin ps --format '{{json .}}' >"$docker_out" 2>"$docker_err"
docker_rc=$?
if $docker_bin compose version >"$compose_out" 2>"$compose_err"; then
compose_rc=0
elif command -v docker-compose >/dev/null 2>&1 && docker-compose version >"$compose_out" 2>"$compose_err"; then
compose_rc=0
else
compose_rc=0
printf '%s\\n' 'docker compose is absent; apply will use raw docker run fallback' >"$compose_out"
fi
else
docker_rc=1
compose_rc=1
: >"$docker_out"
printf '%s\\n' 'docker daemon is not accessible' >"$docker_err"
: >"$compose_out"
printf '%s\\n' 'docker compose is not accessible' >"$compose_err"
fi
{
for port in ${host.appPort} ${host.redisPort}; do
state="free"
if command -v ss >/dev/null 2>&1 && ss -ltn | awk '{print $4}' | grep -E "(:|\\])$port$" >/dev/null 2>&1; then
state="listening"
fi
printf 'port=%s state=%s\\n' "$port" "$state"
done
} >"$ports_out" 2>"$ports_err"
ports_rc=$?
python3 - "$docker_rc" "$compose_rc" "$ports_rc" "$docker_out" "$docker_err" "$compose_out" "$compose_err" "$ports_out" "$ports_err" <<'PY'
import json
import sys
docker_rc, compose_rc, ports_rc = [int(value) for value in sys.argv[1:4]]
paths = sys.argv[4:]
def text(path, limit=4000):
try:
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
except FileNotFoundError:
return ""
payload = {
"ok": docker_rc == 0 and compose_rc == 0 and ports_rc == 0,
"target": "${target.id}",
"runtimeMode": "host-docker",
"route": "${target.route}",
"projectName": "${host.projectName}",
"composePath": "${host.composePath}",
"envPath": "${host.envPath}",
"appDataDir": "${host.appDataDir}",
"image": "${imageRef(sub2api, target)}",
"dependencyImages": ${JSON.stringify(targetDependencyImages(sub2api, target))},
"ports": {
"app": ${host.appPort},
"redis": ${host.redisPort},
"observed": text(paths[4], 2000),
},
"steps": {
"docker": {"exitCode": docker_rc, "stdout": text(paths[0]), "stderr": text(paths[1])},
"dockerCompose": {"exitCode": compose_rc, "stdout": text(paths[2]), "stderr": text(paths[3])},
"portObservation": {"exitCode": ports_rc, "stdout": text(paths[4]), "stderr": text(paths[5])},
},
"valuesPrinted": False,
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
`;
}
export function hostDockerApplyScript(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig, secretMaterial: ExternalActiveSecretMaterial | null): string {
if (secretMaterial === null) throw new Error("host-docker apply requires external-active secret material");
const host = requireHostDockerConfig(target);
const compose = hostDockerComposeYaml(sub2api, target);
const env = hostDockerEnvFile(sub2api, target, secretMaterial);
const composeB64 = Buffer.from(compose, "utf8").toString("base64");
const envB64 = Buffer.from(env, "utf8").toString("base64");
const image = targetImage(sub2api, target);
const dependencyImages = targetDependencyImages(sub2api, target);
const pullCommand = image.pullPolicy === "Always"
? `$docker_bin pull ${shQuote(imageRef(sub2api, target))} >"$pull_out" 2>"$pull_err"\napp_pull_rc=$?\n$docker_bin pull ${shQuote(dependencyImages.redis)} >>"$pull_out" 2>>"$pull_err"\nredis_pull_rc=$?\nif [ "$app_pull_rc" -eq 0 ] && [ "$redis_pull_rc" -eq 0 ]; then pull_rc=0; else pull_rc=1; fi`
: `: >"$pull_out"\n: >"$pull_err"\npull_rc=0`;
const secretSourceSummary = {
sourceRef: secretMaterial.sourceRef,
sourcePath: secretMaterial.sourcePath,
appSourceRef: secretMaterial.appSourceRef,
appSourcePath: secretMaterial.appSourcePath,
sourceAction: secretMaterial.action,
fingerprint: secretMaterial.fingerprint,
valuesPrinted: false,
};
return `
set -u
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
compose_tmp="$tmp/docker-compose.yml"
env_tmp="$tmp/sub2api.env"
printf '%s' '${composeB64}' | base64 -d >"$compose_tmp"
printf '%s' '${envB64}' | base64 -d >"$env_tmp"
docker_out="$tmp/docker.out"
docker_err="$tmp/docker.err"
install_out="$tmp/install.out"
install_err="$tmp/install.err"
pull_out="$tmp/pull.out"
pull_err="$tmp/pull.err"
up_out="$tmp/up.out"
up_err="$tmp/up.err"
health_out="$tmp/health.out"
health_err="$tmp/health.err"
docker_bin=""
if docker ps >/dev/null 2>&1; then
docker_bin="docker"
elif sudo docker ps >/dev/null 2>&1; then
docker_bin="sudo docker"
fi
if [ -n "$docker_bin" ]; then
if $docker_bin compose version >"$docker_out" 2>"$docker_err"; then
compose_cmd="$docker_bin compose"
compose_mode="plugin"
docker_rc=0
elif command -v docker-compose >/dev/null 2>&1 && docker-compose version >"$docker_out" 2>"$docker_err"; then
compose_cmd="docker-compose"
compose_mode="standalone"
docker_rc=0
else
compose_cmd=""
compose_mode="raw-docker"
$docker_bin version >"$docker_out" 2>"$docker_err"
docker_rc=$?
fi
else
compose_cmd=""
compose_mode="none"
docker_rc=1
: >"$docker_out"
printf '%s\\n' 'docker daemon or docker compose is not accessible' >"$docker_err"
fi
install_rc=1
if [ "$docker_rc" -eq 0 ]; then
sudo mkdir -p ${shQuote(host.workDir)} ${shQuote(host.appDataDir)} "$(dirname ${shQuote(host.composePath)})" "$(dirname ${shQuote(host.envPath)})" >"$install_out" 2>"$install_err"
install_rc=$?
if [ "$install_rc" -eq 0 ]; then
sudo install -m 0644 "$compose_tmp" ${shQuote(host.composePath)} >>"$install_out" 2>>"$install_err"
install_rc=$?
fi
if [ "$install_rc" -eq 0 ]; then
current_uid="$(id -u)"
current_gid="$(id -g)"
sudo install -m 0600 -o "$current_uid" -g "$current_gid" "$env_tmp" ${shQuote(host.envPath)} >>"$install_out" 2>>"$install_err"
install_rc=$?
fi
else
: >"$install_out"
: >"$install_err"
fi
if [ "$install_rc" -eq 0 ]; then
${pullCommand}
else
: >"$pull_out"
printf '%s\\n' 'skipped because install failed' >"$pull_err"
pull_rc=1
fi
if [ "$pull_rc" -eq 0 ]; then
if [ -n "$compose_cmd" ]; then
$compose_cmd -p ${shQuote(host.projectName)} -f ${shQuote(host.composePath)} up -d --remove-orphans >"$up_out" 2>"$up_err"
up_rc=$?
else
$docker_bin rm -f ${shQuote(`${host.projectName}-app`)} ${shQuote(`${host.projectName}-redis`)} >"$up_out" 2>"$up_err" || true
$docker_bin run -d --name ${shQuote(`${host.projectName}-redis`)} \\
--restart unless-stopped \\
--network host \\
${shQuote(dependencyImages.redis)} \\
redis-server --save "" --appendonly no --bind 127.0.0.1 --port ${host.redisPort} >>"$up_out" 2>>"$up_err"
redis_run_rc=$?
if [ "$redis_run_rc" -eq 0 ]; then
$docker_bin run -d --name ${shQuote(`${host.projectName}-app`)} \\
--restart unless-stopped \\
--network host \\
--env-file ${shQuote(host.envPath)} \\
-v ${shQuote(`${host.appDataDir}:/app/data`)} \\
${shQuote(imageRef(sub2api, target))} >>"$up_out" 2>>"$up_err"
app_run_rc=$?
else
app_run_rc=1
fi
if [ "$redis_run_rc" -eq 0 ] && [ "$app_run_rc" -eq 0 ]; then up_rc=0; else up_rc=1; fi
fi
else
: >"$up_out"
printf '%s\\n' 'skipped because pull failed' >"$up_err"
up_rc=1
fi
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
sleep 3
done
else
: >"$health_out"
printf '%s\\n' 'skipped because compose up failed' >"$health_err"
fi
python3 - "$docker_rc" "$install_rc" "$pull_rc" "$up_rc" "$health_rc" "$compose_mode" "$docker_out" "$docker_err" "$install_out" "$install_err" "$pull_out" "$pull_err" "$up_out" "$up_err" "$health_out" "$health_err" <<'PY'
import json
import sys
docker_rc, install_rc, pull_rc, up_rc, health_rc = [int(value) for value in sys.argv[1:6]]
docker_mode = sys.argv[6]
paths = sys.argv[7:]
def text(path, limit=4000):
try:
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
except FileNotFoundError:
return ""
payload = {
"ok": docker_rc == 0 and install_rc == 0 and pull_rc == 0 and up_rc == 0 and health_rc == 0,
"target": "${target.id}",
"runtimeMode": "host-docker",
"route": "${target.route}",
"projectName": "${host.projectName}",
"dockerMode": docker_mode,
"composePath": "${host.composePath}",
"envPath": "${host.envPath}",
"appDataDir": "${host.appDataDir}",
"image": "${imageRef(sub2api, target)}",
"secret": {
"requiredKeys": ${JSON.stringify(requiredSecretKeys)},
"externalActiveSource": json.loads(${JSON.stringify(JSON.stringify(secretSourceSummary))}),
"valuesPrinted": False,
},
"steps": {
"docker": {"exitCode": docker_rc, "stdout": text(paths[0]), "stderr": text(paths[1])},
"installFiles": {"exitCode": install_rc, "stdout": text(paths[2]), "stderr": text(paths[3])},
"pull": {"exitCode": pull_rc, "stdout": text(paths[4]), "stderr": text(paths[5])},
"composeUp": {"exitCode": up_rc, "stdout": text(paths[6]), "stderr": text(paths[7])},
"localHealth": {"exitCode": health_rc, "stdout": text(paths[8]), "stderr": text(paths[9])},
},
"valuesPrinted": False,
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
`;
}
export function hostDockerStatusScript(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
const host = requireHostDockerConfig(target);
return `
set -u
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
docker_out="$tmp/docker.out"
docker_err="$tmp/docker.err"
ps_out="$tmp/ps.out"
ps_err="$tmp/ps.err"
health_out="$tmp/health.out"
health_err="$tmp/health.err"
caddy_out="$tmp/caddy.out"
caddy_err="$tmp/caddy.err"
docker_bin=""
if docker ps >/dev/null 2>&1; then
docker_bin="docker"
elif sudo docker ps >/dev/null 2>&1; then
docker_bin="sudo docker"
fi
if [ -n "$docker_bin" ]; then
$docker_bin version --format '{{json .}}' >"$docker_out" 2>"$docker_err"
docker_rc=$?
$docker_bin ps -a --filter label=com.docker.compose.project=${shQuote(host.projectName)} --format '{{json .}}' >"$ps_out" 2>"$ps_err"
$docker_bin ps -a --filter name=${shQuote(`${host.projectName}-`)} --format '{{json .}}' >>"$ps_out" 2>>"$ps_err"
ps_rc=$?
else
docker_rc=1
ps_rc=1
: >"$docker_out"
printf '%s\\n' 'docker daemon is not accessible' >"$docker_err"
: >"$ps_out"
: >"$ps_err"
fi
curl -fsS --max-time 5 http://127.0.0.1:${host.appPort}/health >"$health_out" 2>"$health_err"
health_rc=$?
systemctl is-active caddy >"$caddy_out" 2>"$caddy_err"
caddy_rc=$?
python3 - "$docker_rc" "$ps_rc" "$health_rc" "$caddy_rc" "$docker_out" "$docker_err" "$ps_out" "$ps_err" "$health_out" "$health_err" "$caddy_out" "$caddy_err" <<'PY'
import json
import sys
docker_rc, ps_rc, health_rc, caddy_rc = [int(value) for value in sys.argv[1:5]]
paths = sys.argv[5:]
def text(path, limit=4000):
try:
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
except FileNotFoundError:
return ""
containers = []
for line in text(paths[2], 20000).splitlines():
try:
containers.append(json.loads(line))
except json.JSONDecodeError:
pass
payload = {
"ok": docker_rc == 0 and ps_rc == 0 and health_rc == 0,
"target": "${target.id}",
"runtimeMode": "host-docker",
"route": "${target.route}",
"projectName": "${host.projectName}",
"serviceDns": "host-docker:127.0.0.1:${host.appPort}",
"containers": containers,
"secret": {"managedByThisApply": True, "valuesPrinted": False},
"networkPolicy": {"ok": True, "mode": "not-applicable-host-docker"},
"egressProxy": {"enabled": False},
"sentinel": {"enabled": False, "mode": "not-deployed-host-docker"},
"checks": {
"docker": {"exitCode": docker_rc, "stdout": text(paths[0]), "stderr": text(paths[1])},
"composeContainers": {"exitCode": ps_rc, "stdout": text(paths[2], 8000), "stderr": text(paths[3])},
"localHealth": {"exitCode": health_rc, "stdout": text(paths[4]), "stderr": text(paths[5])},
"caddyService": {"exitCode": caddy_rc, "stdout": text(paths[6]), "stderr": text(paths[7])},
},
"valuesPrinted": False,
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
`;
}
export function hostDockerValidateScript(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
const host = requireHostDockerConfig(target);
const database = targetDatabase(sub2api, target);
const exposure = target.publicExposure?.enabled ? target.publicExposure : null;
const publicProbe = exposure === null ? `
public_rc=0
: >"$tmp/public.out"
: >"$tmp/public.err"
` : `
curl -kfsS --max-time 20 --resolve ${shQuote(`${exposure.dns.hostname}:443:127.0.0.1`)} ${shQuote(`${exposure.publicBaseUrl}/health`)} >"$tmp/public.out" 2>"$tmp/public.err"
public_rc=$?
`;
return `
set -u
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
docker_out="$tmp/docker.out"
docker_err="$tmp/docker.err"
app_out="$tmp/app.out"
app_err="$tmp/app.err"
redis_out="$tmp/redis.out"
redis_err="$tmp/redis.err"
pg_out="$tmp/pg.out"
pg_err="$tmp/pg.err"
health_out="$tmp/health.out"
health_err="$tmp/health.err"
docker_bin=""
if docker ps >/dev/null 2>&1; then
docker_bin="docker"
elif sudo docker ps >/dev/null 2>&1; then
docker_bin="sudo docker"
fi
if [ -n "$docker_bin" ]; then
$docker_bin inspect -f '{{.State.Running}}' ${shQuote(`${host.projectName}-app`)} >"$app_out" 2>"$app_err"
app_rc=$?
$docker_bin exec ${shQuote(`${host.projectName}-redis`)} redis-cli -h 127.0.0.1 -p ${host.redisPort} ping >"$redis_out" 2>"$redis_err"
redis_rc=$?
$docker_bin ps --filter label=com.docker.compose.project=${shQuote(host.projectName)} --format '{{json .}}' >"$docker_out" 2>"$docker_err"
$docker_bin ps --filter name=${shQuote(`${host.projectName}-`)} --format '{{json .}}' >>"$docker_out" 2>>"$docker_err"
docker_rc=$?
else
app_rc=1
redis_rc=1
docker_rc=1
: >"$app_out"
: >"$redis_out"
: >"$docker_out"
printf '%s\\n' 'docker daemon is not accessible' >"$app_err"
printf '%s\\n' 'docker daemon is not accessible' >"$redis_err"
printf '%s\\n' 'docker daemon is not accessible' >"$docker_err"
fi
if command -v pg_isready >/dev/null 2>&1; then
pg_isready -h ${shQuote(database.host)} -p ${database.port} -U ${shQuote(database.user)} -d ${shQuote(database.dbName)} >"$pg_out" 2>"$pg_err"
pg_rc=$?
else
pg_rc=127
: >"$pg_out"
printf '%s\\n' 'pg_isready is not installed on PK01 host' >"$pg_err"
fi
curl -fsS --max-time 10 http://127.0.0.1:${host.appPort}/health >"$health_out" 2>"$health_err"
health_rc=$?
${publicProbe}
python3 - "$docker_rc" "$app_rc" "$redis_rc" "$pg_rc" "$health_rc" "$public_rc" "$docker_out" "$docker_err" "$app_out" "$app_err" "$redis_out" "$redis_err" "$pg_out" "$pg_err" "$health_out" "$health_err" "$tmp/public.out" "$tmp/public.err" <<'PY'
import json
import sys
docker_rc, app_rc, redis_rc, pg_rc, health_rc, public_rc = [int(value) for value in sys.argv[1:7]]
paths = sys.argv[7:]
def text(path, limit=4000):
try:
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
except FileNotFoundError:
return ""
payload = {
"ok": docker_rc == 0 and app_rc == 0 and redis_rc == 0 and pg_rc == 0 and health_rc == 0 and public_rc == 0,
"target": "${target.id}",
"runtimeMode": "host-docker",
"route": "${target.route}",
"serviceDns": "host-docker:127.0.0.1:${host.appPort}",
"checks": {
"dockerComposeProject": {"exitCode": docker_rc, "stdout": text(paths[0], 8000), "stderr": text(paths[1])},
"appContainerRunning": {"exitCode": app_rc, "stdout": text(paths[2]), "stderr": text(paths[3])},
"redisPing": {"exitCode": redis_rc, "stdout": text(paths[4]), "stderr": text(paths[5])},
"postgresLocalPgIsReady": {"exitCode": pg_rc, "host": "${database.host}", "port": ${database.port}, "user": "${database.user}", "dbName": "${database.dbName}", "stdout": text(paths[6]), "stderr": text(paths[7])},
"sub2apiLocalHealth": {"exitCode": health_rc, "stdout": text(paths[8]), "stderr": text(paths[9])},
"sub2apiPublicHealthViaPk01Caddy": {"exitCode": public_rc, "stdout": text(paths[10]), "stderr": text(paths[11])},
"sentinel": {"ok": True, "mode": "not-deployed-host-docker"},
"egressProxy": {"ok": True, "mode": "not-deployed-host-docker"},
},
"valuesPrinted": False,
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
`;
}
function yamlString(value: string): string {
return JSON.stringify(value);
}
function envFileValue(value: string): string {
if (/^[A-Za-z0-9_./:@,+-]*$/u.test(value)) return value;
return JSON.stringify(value);
}
+22 -5
View File
@@ -14,7 +14,7 @@ import { capture, compactCapture, parseJsonOutput, prepareFrpcSecret, shQuote }
import { yamlBooleanField, yamlFieldLabel, yamlIntegerField } from "../platform-infra-ops-library";
import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets";
import type { ManagedResourceCleanupPlan, Sub2ApiConfig, Sub2ApiEgressProxyConfig, Sub2ApiTargetConfig } from "./entry";
import type { ExternalDatabaseConfig, ManagedResourceCleanupPlan, Sub2ApiConfig, Sub2ApiEgressProxyConfig, Sub2ApiTargetConfig } from "./entry";
import { isKubernetesName, stringField } from "./config";
import { codexPoolConfigPath, configPath, manifestPath } from "./entry";
@@ -95,7 +95,22 @@ export function isExternalTarget(target: Sub2ApiTargetConfig): boolean {
return target.databaseMode === "external-pending" || target.databaseMode === "external-active";
}
export function isHostDockerTarget(target: Sub2ApiTargetConfig): boolean {
return target.runtimeMode === "host-docker";
}
export function targetDatabase(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): ExternalDatabaseConfig {
const hostDocker = target.hostDocker;
if (hostDocker === null) return sub2api.runtime.database;
return {
...sub2api.runtime.database,
host: hostDocker.databaseHost,
sslMode: hostDocker.databaseSslMode,
};
}
export function manifest(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
if (isHostDockerTarget(target)) return "";
if (isExternalTarget(target)) return externalPendingManifest(sub2api, target);
const template = readFileSync(manifestPath, "utf8");
const urlAllowlist = sub2api.security.urlAllowlist;
@@ -143,10 +158,10 @@ export function managedResourceCleanupPlan(sub2api: Sub2ApiConfig, target: Sub2A
...sub2api.defaults.cleanup.redisPersistentState,
},
publicExposure: {
enabled: target.publicExposure?.enabled === true,
deploymentName: target.publicExposure?.frpc.deploymentName ?? sub2api.defaults.cleanup.publicExposure.deploymentName,
enabled: target.publicExposure?.enabled === true && target.publicExposure.mode === "frp",
deploymentName: target.publicExposure?.frpc?.deploymentName ?? sub2api.defaults.cleanup.publicExposure.deploymentName,
configMapName: sub2api.defaults.cleanup.publicExposure.configMapName,
secretName: target.publicExposure?.frpc.secretName ?? sub2api.defaults.cleanup.publicExposure.secretName,
secretName: target.publicExposure?.frpc?.secretName ?? sub2api.defaults.cleanup.publicExposure.secretName,
},
egressProxy: {
enabled: target.egressProxy?.enabled === true,
@@ -187,7 +202,7 @@ export function codexPoolSentinelResourceNames(): ManagedResourceCleanupPlan["se
export function externalPendingManifest(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
const urlAllowlist = sub2api.security.urlAllowlist;
const database = sub2api.runtime.database;
const database = targetDatabase(sub2api, target);
const databaseStatus = target.databaseMode === "external-active" ? "external-db-active" : "pending-external-db";
const redisService = sub2api.runtime.redis.serviceName;
const appReplicas = target.appReplicas;
@@ -522,6 +537,8 @@ export function sub2ApiProxyEnv(target: Sub2ApiTargetConfig): { httpProxy: strin
export function renderPublicExposureManifest(target: Sub2ApiTargetConfig): string {
const exposure = target.publicExposure;
if (exposure === null || !exposure.enabled) return "";
if (exposure.mode === "pk01-local") return "";
if (exposure.frpc === null) throw new Error(`publicExposure.frpc is required for target ${target.id} when mode=frp`);
return `---
apiVersion: apps/v1
kind: Deployment
+30 -9
View File
@@ -17,7 +17,7 @@ import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile,
import { apply, plan, status } from "./actions";
import { defaultSub2ApiTargetId, readSub2ApiConfig, validateOptions } from "./config";
import { configPath, platformInfraHelp, serviceName } from "./entry";
import { resolveTarget } from "./manifest";
import { isHostDockerTarget, resolveTarget } from "./manifest";
import { validate } from "./policy";
export function sub2ApiHelpTargetSummary(): Record<string, unknown> {
@@ -27,7 +27,8 @@ export function sub2ApiHelpTargetSummary(): Record<string, unknown> {
default: sub2api.defaults.targetId,
namespace: target.namespace,
service: serviceName,
serviceDns: `${serviceName}.${target.namespace}.svc.cluster.local:8080`,
runtimeMode: target.runtimeMode,
serviceDns: isHostDockerTarget(target) ? `host-docker:127.0.0.1:${target.hostDocker?.appPort ?? 0}` : `${serviceName}.${target.namespace}.svc.cluster.local:8080`,
exposure: target.publicExposure?.enabled === true ? "yaml-controlled-public-exposure" : "k3s-cluster-internal-only",
resourceLimits: "unset-by-policy",
versionConfigPath: configPath,
@@ -145,6 +146,8 @@ function renderSub2ApiStatus(result: Record<string, unknown>): RenderedCliResult
const secret = record(summary.secret);
const egress = record(summary.egressProxy);
const networkPolicy = record(summary.networkPolicy);
const checks = record(summary.checks);
const isHostDocker = stringValue(summary.runtimeMode, "") === "host-docker";
const deployments = arrayRecords(summary.deployments)
.filter((item) => stringValue(item.name).startsWith("sub2api"))
.map((item) => [
@@ -153,6 +156,13 @@ function renderSub2ApiStatus(result: Record<string, unknown>): RenderedCliResult
`${stringValue(item.readyReplicas, "0")}/${stringValue(item.desired, "0")}`,
arrayValues(item.images).slice(0, 1).map((value) => stringValue(value)).join(",") || "-",
]);
const containers = arrayRecords(summary.containers)
.map((item) => [
stringValue(item.Names, stringValue(item.names)),
stringValue(item.State, stringValue(item.state)),
stringValue(item.Status, stringValue(item.status)),
stringValue(item.Image, stringValue(item.image)),
]);
const services = arrayRecords(summary.services)
.filter((item) => stringValue(item.name).startsWith("sub2api"))
.map((item) => [
@@ -165,18 +175,29 @@ function renderSub2ApiStatus(result: Record<string, unknown>): RenderedCliResult
...table(["TARGET", "ROUTE", "NAMESPACE", "STATUS"], [[stringValue(summary.target, stringValue(target.id)), stringValue(summary.route, stringValue(target.route)), stringValue(summary.namespace, stringValue(target.namespace)), stringValue(summary.status, result.ok === false ? "failed" : "ok")]]),
"",
"WORKLOADS",
...(deployments.length === 0 ? ["-"] : table(["NAME", "READY", "REPLICAS", "IMAGE"], deployments)),
...(deployments.length > 0
? table(["NAME", "READY", "REPLICAS", "IMAGE"], deployments)
: containers.length > 0
? table(["NAME", "STATE", "STATUS", "IMAGE"], containers)
: ["-"]),
"",
"SERVICES",
...(services.length === 0 ? ["-"] : table(["NAME", "TYPE", "PORTS"], services)),
"",
"CHECKS",
...table(["CHECK", "VALUE", "DETAIL"], [
["namespace", boolText(summary.namespaceExists), stringValue(summary.namespace)],
["secret", boolText(secret.ready), `name=${stringValue(secret.name, "-")} missing=${arrayValues(secret.missingKeys).length}`],
["egressProxy", boolText(egress.aligned), `enabled=${boolText(egress.enabled)} valuesPrinted=false`],
["networkPolicy", boolText(networkPolicy.ok), `required=${stringValue(networkPolicy.requiredName, "-")}`],
]),
...table(["CHECK", "VALUE", "DETAIL"], isHostDocker
? [
["localHealth", boolText(record(checks.localHealth).exitCode === 0), "http://127.0.0.1 health"],
["caddy", boolText(record(checks.caddyService).exitCode === 0), "PK01 Caddy service"],
["egressProxy", boolText(egress.enabled === false), "not deployed"],
["sentinel", boolText(record(summary.sentinel).enabled === false), "not deployed"],
]
: [
["namespace", boolText(summary.namespaceExists), stringValue(summary.namespace)],
["secret", boolText(secret.ready), `name=${stringValue(secret.name, "-")} missing=${arrayValues(secret.missingKeys).length}`],
["egressProxy", boolText(egress.aligned), `enabled=${boolText(egress.enabled)} valuesPrinted=false`],
["networkPolicy", boolText(networkPolicy.ok), `required=${stringValue(networkPolicy.requiredName, "-")}`],
]),
"",
"NEXT",
` validate: bun scripts/cli.ts platform-infra sub2api validate --target ${stringValue(summary.target, stringValue(target.id))}`,
@@ -16,7 +16,7 @@ import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile,
import type { EgressProxySubscriptionCandidateSummary, EgressProxySubscriptionDiagnostics, Sub2ApiEgressProxyConfig, Sub2ApiPublicExposureConfig, Sub2ApiTargetConfig } from "./entry";
import { status } from "./actions";
import { renderPk01CaddyService, renderPk01Caddyfile } from "./apply-script";
import { publicExposureUpstream, renderPk01CaddyService, renderPk01Caddyfile } from "./apply-script";
import { asRecordOrNull, boolField } from "./utils";
export type SubscriptionNode =
@@ -436,6 +436,18 @@ if [ -f "$download_cache" ]; then stat -c 'cache_bytes=%s cache_mtime=%y' "$down
export function pk01PublicExposureScript(target: Sub2ApiTargetConfig, exposure: Sub2ApiPublicExposureConfig): string {
const caddyfile = renderPk01Caddyfile(target, exposure);
const serviceUnit = renderPk01CaddyService(exposure);
const exposureMode = exposure.mode === "pk01-local" ? "pk01-caddy-local-docker" : "pk01-caddy-frp-direct";
const dataPath = exposure.mode === "pk01-local"
? `client -> PK01 Caddy -> ${publicExposureUpstream(target, exposure)} -> PK01 Docker Sub2API`
: `client -> PK01 Caddy -> PK01 frps remotePort -> ${target.id} frpc -> Sub2API`;
const frpSummary = exposure.mode === "frp" && exposure.frpc !== null
? {
server: `${exposure.frpc.serverAddr}:${exposure.frpc.serverPort}`,
remotePort: exposure.frpc.remotePort,
proxyName: exposure.frpc.proxyName,
}
: null;
const frpSummaryPython = frpSummary === null ? "None" : `json.loads(${JSON.stringify(JSON.stringify(frpSummary))})`;
const caddyfileB64 = Buffer.from(caddyfile, "utf8").toString("base64");
const serviceUnitB64 = Buffer.from(serviceUnit, "utf8").toString("base64");
const caddyDownloadProxyArgs = exposure.pk01.caddyDownloadProxyUrl === null ? "" : `--proxy ${shQuote(exposure.pk01.caddyDownloadProxyUrl)}`;
@@ -606,11 +618,11 @@ def text(path, limit=4000):
payload = {
"ok": download_rc == 0 and install_rc == 0 and validate_rc == 0 and pikanode_rc == 0 and caddy_rc == 0,
"target": "${target.id}",
"mode": "pk01-caddy-frp-direct",
"mode": ${JSON.stringify(exposureMode)},
"publicBaseUrl": "${exposure.publicBaseUrl}",
"hostname": "${exposure.dns.hostname}",
"expectedA": "${exposure.dns.expectedA}",
"dataPath": "client -> PK01 Caddy -> PK01 frps remotePort -> ${target.id} frpc -> Sub2API",
"dataPath": ${JSON.stringify(dataPath)},
"pikanodeRole": "pikapython.com upstream only; ${exposure.dns.hostname} does not pass through pikanode Express",
"caddy": {
"binaryPath": "${exposure.pk01.caddyBinaryPath}",
@@ -618,11 +630,7 @@ payload = {
"serviceName": "${exposure.pk01.caddyServiceName}",
"downloadProxy": {"mode": download_proxy_mode, "url": download_proxy_url or None},
},
"frp": {
"server": "${exposure.frpc.serverAddr}:${exposure.frpc.serverPort}",
"remotePort": ${exposure.frpc.remotePort},
"proxyName": "${exposure.frpc.proxyName}",
},
"frp": ${frpSummaryPython},
"steps": {
"downloadCaddy": {"exitCode": download_rc, "action": download_action, "stdout": text(paths[0]), "stderr": text(paths[1])},
"installConfig": {"exitCode": install_rc, "stdout": text(paths[2]), "stderr": text(paths[3])},
+34 -2
View File
@@ -18,7 +18,8 @@ import type { Sub2ApiConfig, Sub2ApiTargetConfig } from "./entry";
import type { DisclosureOptions, PolicyCheck } from "./options";
import { readSub2ApiConfig } from "./config";
import { serviceName } from "./entry";
import { isExternalTarget, resolveTarget } from "./manifest";
import { hostDockerValidateScript } from "./host-docker";
import { isExternalTarget, isHostDockerTarget, resolveTarget, targetHasSentinel } from "./manifest";
import { escapeRegExp, hasAllowAllNetworkPolicy, hasDeploymentReplicas } from "./secrets-and-egress";
import { boolField, validateExternalPendingScript } from "./utils";
import { validateExternalActiveScript, validateScript } from "./validate-script";
@@ -26,7 +27,9 @@ import { validateExternalActiveScript, validateScript } from "./validate-script"
export async function validate(config: UniDeskConfig, options: DisclosureOptions): Promise<Record<string, unknown>> {
const sub2api = readSub2ApiConfig();
const target = resolveTarget(sub2api, options.targetId);
const script = target.databaseMode === "external-pending"
const script = isHostDockerTarget(target)
? hostDockerValidateScript(sub2api, target)
: target.databaseMode === "external-pending"
? validateExternalPendingScript(sub2api, target)
: target.databaseMode === "external-active"
? validateExternalActiveScript(sub2api, target)
@@ -60,6 +63,35 @@ export async function validate(config: UniDeskConfig, options: DisclosureOptions
}
export function policyChecks(sub2api: Sub2ApiConfig, yaml: string, target: Sub2ApiTargetConfig): PolicyCheck[] {
if (isHostDockerTarget(target)) {
return [
{
name: "host-docker-no-kubernetes-manifest",
ok: yaml.trim().length === 0,
detail: "PK01 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.",
},
{
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.",
},
{
name: "host-docker-no-sentinel",
ok: !targetHasSentinel(sub2api, target),
detail: "PK01 bare Docker deployment must not deploy the 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.",
},
];
}
const cleanup = sub2api.defaults.cleanup;
const redisService = sub2api.runtime.redis.serviceName;
const checks: PolicyCheck[] = [
@@ -95,6 +95,8 @@ export function prepareExternalActiveSecret(sub2api: Sub2ApiConfig): ExternalAct
export function preparePublicExposureSecret(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): PublicExposureSecretMaterial | null {
const exposure = target.publicExposure;
if (exposure === null || !exposure.enabled) return null;
if (exposure.mode === "pk01-local") return null;
if (exposure.frpc === null) throw new Error(`publicExposure.frpc is required for target ${target.id} when mode=frp`);
return prepareFrpcSecret({
secretRoot: secretRoot(sub2api),
exposure,