From d225fd1a6133f788c0d85ee33147101f76c8c5bf Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 28 Jun 2026 14:24:08 +0000 Subject: [PATCH] fix: move sub2api runtime to PK01 host docker --- config/platform-infra/sub2api-codex-pool.yaml | 13 +- config/platform-infra/sub2api.yaml | 107 ++-- .../platform-infra-sub2api-codex/actions.ts | 46 +- .../platform-infra-sub2api-codex/config.ts | 17 +- .../public-exposure.ts | 56 +- .../remote-python-sync.ts | 26 +- .../runtime-target.ts | 53 +- .../src/platform-infra-sub2api-codex/types.ts | 25 +- scripts/src/platform-infra/actions.ts | 87 ++- scripts/src/platform-infra/apply-script.ts | 11 +- scripts/src/platform-infra/config.ts | 85 ++- scripts/src/platform-infra/entry.ts | 27 +- scripts/src/platform-infra/host-docker.ts | 593 ++++++++++++++++++ scripts/src/platform-infra/manifest.ts | 27 +- scripts/src/platform-infra/options.ts | 39 +- .../platform-infra/pk01-public-exposure.ts | 24 +- scripts/src/platform-infra/policy.ts | 36 +- .../src/platform-infra/secrets-and-egress.ts | 2 + 18 files changed, 1088 insertions(+), 186 deletions(-) create mode 100644 scripts/src/platform-infra/host-docker.ts diff --git a/config/platform-infra/sub2api-codex-pool.yaml b/config/platform-infra/sub2api-codex-pool.yaml index e0125497..1ae54eb4 100644 --- a/config/platform-infra/sub2api-codex-pool.yaml +++ b/config/platform-infra/sub2api-codex-pool.yaml @@ -143,6 +143,15 @@ manualAccounts: kind: proxy provider: target-egress-proxy description: Bind a protected manual account to the selected target's YAML-declared egress proxy. + pk01-local-egress-proxy: + enabled: true + kind: proxy + provider: fixed-http-proxy + description: Bind a protected manual account to PK01 local provider-gateway egress. + fixedProxy: + protocol: http + host: 127.0.0.1 + port: 18789 unified-pool-group: enabled: true kind: group @@ -153,8 +162,8 @@ manualAccounts: reason: Manually configured in Sub2API; keep outside UniDesk-managed Codex pool and sentinel control. proxyBinding: enabled: true - source: active-target-egress-proxy - proxyName: platform-infra-sub2api-egress-proxy + source: pk01-local-egress-proxy + proxyName: platform-infra-sub2api-pk01-local-egress-proxy groupBinding: enabled: true source: unified-pool-group diff --git a/config/platform-infra/sub2api.yaml b/config/platform-infra/sub2api.yaml index b1c9b496..7293a364 100644 --- a/config/platform-infra/sub2api.yaml +++ b/config/platform-infra/sub2api.yaml @@ -10,7 +10,7 @@ metadata: - 340 defaults: - targetId: D601 + targetId: PK01 cleanup: externalDbState: postgresStatefulSetName: sub2api-postgres @@ -69,9 +69,10 @@ targets: - kubernetes.default.svc - 127.0.0.1:5000 - localhost:5000 - - id: D601 - route: D601:k3s + - id: PK01 + route: PK01 namespace: platform-infra + runtimeMode: host-docker role: active enabled: true databaseMode: external-active @@ -79,59 +80,45 @@ targets: appReplicas: 1 redisReplicas: 1 image: - repository: 127.0.0.1:5000/platform-infra/sub2api + repository: docker.1panel.live/weishaw/sub2api tag: 0.1.138 pullPolicy: IfNotPresent dependencyImages: - postgres: docker.m.daocloud.io/library/postgres:18-alpine redis: docker.m.daocloud.io/library/redis:8-alpine - codexPool: - sentinelImageBuild: - baseImageCachePolicy: local-if-present - noProxy: - - localhost - - 127.0.0.1 - - ::1 - - host.docker.internal - - 74.48.78.17 - - 192.168.0.0/16 - - 10.0.0.0/8 - - 172.16.0.0/12 - - 10.42.0.0/16 - - 10.43.0.0/16 - - .svc - - .svc.cluster.local - - .cluster.local - - kubernetes - - kubernetes.default - - kubernetes.default.svc - - 127.0.0.1:5000 - - localhost:5000 + hostDocker: + projectName: sub2api + workDir: /opt/unidesk/platform-infra/sub2api + composePath: /opt/unidesk/platform-infra/sub2api/docker-compose.yml + envPath: /opt/unidesk/platform-infra/sub2api/sub2api.env + appDataDir: /var/lib/unidesk/platform-infra/sub2api/data + appPort: 18080 + redisPort: 16379 + databaseHost: 127.0.0.1 + databaseSslMode: require + 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 publicExposure: enabled: true + mode: pk01-local publicBaseUrl: https://api.pikapython.com dns: hostname: api.pikapython.com expectedA: 82.156.23.220 resolvers: [1.1.1.1, 8.8.8.8, 223.5.5.5, 114.114.114.114] - frpc: - deploymentName: sub2api-frpc - secretName: sub2api-frpc-secrets - secretKey: frpc.toml - image: 127.0.0.1:5000/hwlab/frpc:v0.68.1 - serverAddr: 82.156.23.220 - serverPort: 22000 - proxyName: platform-infra-sub2api-d601-api - remotePort: 22098 - localIP: sub2api.platform-infra.svc.cluster.local - localPort: 8080 - tokenSourceRef: platform-infra/pk01-frp.env - tokenSourceKey: FRP_TOKEN + local: + upstreamHost: 127.0.0.1 + upstreamPort: 18080 pk01: route: PK01 caddyBinaryPath: /usr/local/bin/caddy caddyDownloadUrl: https://caddyserver.com/api/download?os=linux&arch=amd64 - caddyDownloadProxyUrl: http://127.0.0.1:18789 caddyConfigPath: /etc/caddy/Caddyfile caddyServiceName: caddy caddyStorageDir: /var/lib/caddy @@ -141,32 +128,15 @@ targets: pikanodeImage: pikanode pikanodeHttpHostPort: 18888 responseHeaderTimeoutSeconds: 600 - egressProxy: - enabled: true - deploymentName: sub2api-egress-proxy - serviceName: sub2api-egress-proxy - secretName: sub2api-egress-proxy-config - secretKey: config.json - image: 127.0.0.1:5000/platform-infra/sing-box:latest - imagePullPolicy: IfNotPresent - listenPort: 10808 - hostNetwork: true - sourceConfigRef: config/platform-infra/egress-proxy-sources.yaml#sources.master-shadowsocks - applyToSub2Api: true - applyToSentinel: true - noProxy: - - localhost - - 127.0.0.1 - - ::1 - - .svc - - .cluster.local - - 10.0.0.0/8 - - 172.16.0.0/12 - - 192.168.0.0/16 - - 82.156.23.220 - - 74.48.78.17 - - hyueapi.com - - .hyueapi.com + - id: D601 + route: D601:k3s + namespace: platform-infra + role: retired + enabled: true + databaseMode: external-pending + redisMode: local-ephemeral + appReplicas: 0 + redisReplicas: 0 - id: D518 route: D518:k3s namespace: platform-infra @@ -289,8 +259,7 @@ runtime: mode: empty-dir sentinel: mode: singleton - enabledOnTargets: - - D601 + enabledOnTargets: [] security: urlAllowlist: enabled: false diff --git a/scripts/src/platform-infra-sub2api-codex/actions.ts b/scripts/src/platform-infra-sub2api-codex/actions.ts index cfbf1244..dbbb2527 100644 --- a/scripts/src/platform-infra-sub2api-codex/actions.ts +++ b/scripts/src/platform-infra-sub2api-codex/actions.ts @@ -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 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); diff --git a/scripts/src/platform-infra-sub2api-codex/config.ts b/scripts/src/platform-infra-sub2api-codex/config.ts index edade6e2..c2dee17d 100644 --- a/scripts/src/platform-infra-sub2api-codex/config.ts +++ b/scripts/src/platform-infra-sub2api-codex/config.ts @@ -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; } diff --git a/scripts/src/platform-infra-sub2api-codex/public-exposure.ts b/scripts/src/platform-infra-sub2api-codex/public-exposure.ts index dd330671..eb2dd32a 100644 --- a/scripts/src/platform-infra-sub2api-codex/public-exposure.ts +++ b/scripts/src/platform-infra-sub2api-codex/public-exposure.ts @@ -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 { - 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): 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")}, diff --git a/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts b/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts index 86b615e9..463d2711 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts @@ -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") diff --git a/scripts/src/platform-infra-sub2api-codex/runtime-target.ts b/scripts/src/platform-infra-sub2api-codex/runtime-target.ts index 9e610b32..f059fb3f 100644 --- a/scripts/src/platform-infra-sub2api-codex/runtime-target.ts +++ b/scripts/src/platform-infra-sub2api-codex/runtime-target.ts @@ -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, targetId: string): CodexPoolRuntimeTarget["sentinelImageBuild"] { +export function readTargetSentinelImageBuild(raw: Record, 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, targe }; } -export function readTargetPublicExposure(raw: Record, targetId: string): PublicServiceExposure | null { +export function readTargetPublicExposure(raw: Record, 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, 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; } diff --git a/scripts/src/platform-infra-sub2api-codex/types.ts b/scripts/src/platform-infra-sub2api-codex/types.ts index 76e14d45..07c53cc1 100644 --- a/scripts/src/platform-infra-sub2api-codex/types.ts +++ b/scripts/src/platform-infra-sub2api-codex/types.ts @@ -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 { diff --git a/scripts/src/platform-infra/actions.ts b/scripts/src/platform-infra/actions.ts index 8da28648..6e3d9030 100644 --- a/scripts/src/platform-infra/actions.ts +++ b/scripts/src/platform-infra/actions.ts @@ -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 { 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 { 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 { 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 { 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 { 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 { 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 { : 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> { 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 { diff --git a/scripts/src/platform-infra/apply-script.ts b/scripts/src/platform-infra/apply-script.ts index 3e975aa4..078b4ea0 100644 --- a/scripts/src/platform-infra/apply-script.ts +++ b/scripts/src/platform-infra/apply-script.ts @@ -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 diff --git a/scripts/src/platform-infra/config.ts b/scripts/src/platform-infra/config.ts index c7e9f088..372bd629 100644 --- a/scripts/src/platform-infra/config.ts +++ b/scripts/src/platform-infra/config.ts @@ -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, 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, 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, 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(); for (const target of targets) { @@ -188,6 +193,37 @@ export function parseTargets(root: Record, 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; + 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, path: string): Partial { 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; 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://`); diff --git a/scripts/src/platform-infra/entry.ts b/scripts/src/platform-infra/entry.ts index c14842a1..3c2a20f5 100644 --- a/scripts/src/platform-infra/entry.ts +++ b/scripts/src/platform-infra/entry.ts @@ -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; dependencyImages: Partial; + 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; diff --git a/scripts/src/platform-infra/host-docker.ts b/scripts/src/platform-infra/host-docker.ts new file mode 100644 index 00000000..0ab99a98 --- /dev/null +++ b/scripts/src/platform-infra/host-docker.ts @@ -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 { + 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 = { + 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); +} diff --git a/scripts/src/platform-infra/manifest.ts b/scripts/src/platform-infra/manifest.ts index bf068d65..bb3cd896 100644 --- a/scripts/src/platform-infra/manifest.ts +++ b/scripts/src/platform-infra/manifest.ts @@ -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 diff --git a/scripts/src/platform-infra/options.ts b/scripts/src/platform-infra/options.ts index 220d8178..3dade8e2 100644 --- a/scripts/src/platform-infra/options.ts +++ b/scripts/src/platform-infra/options.ts @@ -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 { @@ -27,7 +27,8 @@ export function sub2ApiHelpTargetSummary(): Record { 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): 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): 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): 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))}`, diff --git a/scripts/src/platform-infra/pk01-public-exposure.ts b/scripts/src/platform-infra/pk01-public-exposure.ts index 5e71ff8d..5a999d3e 100644 --- a/scripts/src/platform-infra/pk01-public-exposure.ts +++ b/scripts/src/platform-infra/pk01-public-exposure.ts @@ -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])}, diff --git a/scripts/src/platform-infra/policy.ts b/scripts/src/platform-infra/policy.ts index fba4a7ba..834cb277 100644 --- a/scripts/src/platform-infra/policy.ts +++ b/scripts/src/platform-infra/policy.ts @@ -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> { 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[] = [ diff --git a/scripts/src/platform-infra/secrets-and-egress.ts b/scripts/src/platform-infra/secrets-and-egress.ts index b6328555..98b662b1 100644 --- a/scripts/src/platform-infra/secrets-and-egress.ts +++ b/scripts/src/platform-infra/secrets-and-egress.ts @@ -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,