fix: move sub2api runtime to PK01 host docker

This commit is contained in:
Codex
2026-06-28 14:24:08 +00:00
parent 1d07df3df1
commit d225fd1a61
18 changed files with 1088 additions and 186 deletions
+55 -32
View File
@@ -19,7 +19,8 @@ import type { ApplyOptions, DisclosureOptions, TargetOptions } from "./options";
import { applyScript, dryRunScript } from "./apply-script";
import { readSub2ApiConfig } from "./config";
import { configPath, fieldManager, manifestPath, serviceName } from "./entry";
import { imageRef, isExternalTarget, managedResourceCleanupPlan, manifest, resolveTarget, targetDependencyImages, targetHasSentinel, targetImage } from "./manifest";
import { hostDockerApplyScript, hostDockerDryRunScript, hostDockerPlanSummary, hostDockerStatusScript } from "./host-docker";
import { imageRef, isExternalTarget, isHostDockerTarget, managedResourceCleanupPlan, manifest, resolveTarget, targetDatabase, targetDependencyImages, targetHasSentinel, targetImage } from "./manifest";
import { applyPk01PublicExposure } from "./pk01-public-exposure";
import { policyChecks } from "./policy";
import { prepareEgressProxySecret, prepareExternalActiveSecret, preparePublicExposureSecret } from "./secrets-and-egress";
@@ -29,21 +30,25 @@ import { boolField } from "./utils";
export function publicExposureSummary(exposure: Sub2ApiPublicExposureConfig): Record<string, unknown> {
return {
enabled: exposure.enabled,
mode: exposure.mode,
publicBaseUrl: exposure.publicBaseUrl,
dns: exposure.dns,
frpc: {
deploymentName: exposure.frpc.deploymentName,
secretName: exposure.frpc.secretName,
image: exposure.frpc.image,
serverAddr: exposure.frpc.serverAddr,
serverPort: exposure.frpc.serverPort,
proxyName: exposure.frpc.proxyName,
remotePort: exposure.frpc.remotePort,
localIP: exposure.frpc.localIP,
localPort: exposure.frpc.localPort,
tokenSourceRef: exposure.frpc.tokenSourceRef,
valuesPrinted: false,
},
local: exposure.local,
frpc: exposure.frpc === null
? null
: {
deploymentName: exposure.frpc.deploymentName,
secretName: exposure.frpc.secretName,
image: exposure.frpc.image,
serverAddr: exposure.frpc.serverAddr,
serverPort: exposure.frpc.serverPort,
proxyName: exposure.frpc.proxyName,
remotePort: exposure.frpc.remotePort,
localIP: exposure.frpc.localIP,
localPort: exposure.frpc.localPort,
tokenSourceRef: exposure.frpc.tokenSourceRef,
valuesPrinted: false,
},
pk01: {
route: exposure.pk01.route,
mode: "caddy-edge",
@@ -92,6 +97,8 @@ export function plan(options: TargetOptions): Record<string, unknown> {
const target = resolveTarget(sub2api, options.targetId);
const yaml = manifest(sub2api, target);
const policy = policyChecks(sub2api, yaml, target);
const database = targetDatabase(sub2api, target);
const hostDocker = isHostDockerTarget(target) ? hostDockerPlanSummary(sub2api, target) : null;
return {
ok: policy.every((check) => check.ok),
action: "platform-infra-sub2api-plan",
@@ -99,11 +106,12 @@ export function plan(options: TargetOptions): Record<string, unknown> {
id: target.id,
route: target.route,
namespace: target.namespace,
runtimeMode: target.runtimeMode,
role: target.role,
manifestPath,
configPath,
fieldManager,
serviceDns: `${serviceName}.${target.namespace}.svc.cluster.local:8080`,
serviceDns: isHostDockerTarget(target) ? `host-docker:127.0.0.1:${target.hostDocker?.appPort ?? 0}` : `${serviceName}.${target.namespace}.svc.cluster.local:8080`,
},
config: {
path: configPath,
@@ -115,10 +123,12 @@ export function plan(options: TargetOptions): Record<string, unknown> {
dependencyImages: targetDependencyImages(sub2api, target),
security: sub2api.security,
target: {
runtimeMode: target.runtimeMode,
databaseMode: target.databaseMode,
redisMode: target.redisMode,
appReplicas: target.appReplicas,
redisReplicas: target.redisReplicas,
hostDocker,
publicExposure: target.publicExposure === null ? null : publicExposureSummary(target.publicExposure),
egressProxy: target.egressProxy === null ? null : egressProxySummary(target.egressProxy),
},
@@ -128,12 +138,12 @@ export function plan(options: TargetOptions): Record<string, unknown> {
sourceKeys: sub2api.runtime.database.sourceKeys,
secretName: sub2api.runtime.database.secretName,
passwordKey: sub2api.runtime.database.passwordKey,
host: sub2api.runtime.database.host,
port: sub2api.runtime.database.port,
user: sub2api.runtime.database.user,
dbName: sub2api.runtime.database.dbName,
sslMode: sub2api.runtime.database.sslMode,
pendingAllowed: sub2api.runtime.database.pendingAllowed,
host: database.host,
port: database.port,
user: database.user,
dbName: database.dbName,
sslMode: database.sslMode,
pendingAllowed: database.pendingAllowed,
} : null,
},
decision: {
@@ -141,20 +151,26 @@ export function plan(options: TargetOptions): Record<string, unknown> {
namespace: target.namespace,
reason: target.databaseMode === "external-pending"
? `${target.id} is a standby Sub2API platform-infra target prepared through YAML; it stays scaled to zero until this target is promoted in YAML.`
: isHostDockerTarget(target)
? `${target.id} is activated as a PK01 host-Docker Sub2API target against PK01 local PostgreSQL without k3s or sentinel.`
: target.databaseMode === "external-active"
? `${target.id} is activated as a platform-infra Sub2API target against the external PK01 PostgreSQL runtime while keeping app state outside the k3s node.`
: `${target.id} is a bundled active Sub2API platform-infra target.`,
exposure: target.publicExposure?.enabled
? `Public HTTPS ${target.publicExposure.publicBaseUrl} through PK01 Caddy and ${target.id} frpc; no master server forwarding and no Kubernetes Ingress/NodePort/LoadBalancer.`
? isHostDockerTarget(target)
? `Public HTTPS ${target.publicExposure.publicBaseUrl} through PK01 Caddy to PK01 local Docker; no D601 egress, no k3s Ingress/NodePort/LoadBalancer.`
: `Public HTTPS ${target.publicExposure.publicBaseUrl} through PK01 Caddy and ${target.id} frpc; no master server forwarding and no Kubernetes Ingress/NodePort/LoadBalancer.`
: "ClusterIP only; no public ingress or node-level exposure.",
resourcePolicy: "No Kubernetes CPU/memory requests or limits, matching issue #220.",
resourcePolicy: isHostDockerTarget(target) ? "PK01 host-Docker compose is controlled by YAML; no Kubernetes resources are rendered." : "No Kubernetes CPU/memory requests or limits, matching issue #220.",
imageVersionControl: "Sub2API image repository/tag/pullPolicy are controlled by config/platform-infra/sub2api.yaml in the UniDesk repository.",
urlAllowlistControl: "Sub2API upstream URL validation options are controlled by config/platform-infra/sub2api.yaml and rendered to SECURITY_URL_ALLOWLIST_* env vars.",
networkPolicy: "NetworkPolicy/allow-all is rendered with the deployment so kube-router cannot silently default-deny Sub2API cross-pod traffic.",
networkPolicy: isHostDockerTarget(target) ? "Not applicable for PK01 host-Docker deployment." : "NetworkPolicy/allow-all is rendered with the deployment so kube-router cannot silently default-deny Sub2API cross-pod traffic.",
publicExposure: target.publicExposure?.enabled
? {
mode: "pk01-caddy-frp-direct",
dataPath: `client -> PK01 Caddy -> PK01 frps remotePort -> ${target.id} frpc -> Sub2API`,
mode: target.publicExposure.mode === "pk01-local" ? "pk01-caddy-local-docker" : "pk01-caddy-frp-direct",
dataPath: target.publicExposure.mode === "pk01-local"
? `client -> PK01 Caddy -> ${target.publicExposure.local?.upstreamHost}:${target.publicExposure.local?.upstreamPort} -> PK01 Docker Sub2API`
: `client -> PK01 Caddy -> PK01 frps remotePort -> ${target.id} frpc -> Sub2API`,
pikanodeRole: `pikapython.com upstream only; ${target.publicExposure.dns.hostname} does not pass through pikanode Express`,
publicBaseUrl: target.publicExposure.publicBaseUrl,
hostname: target.publicExposure.dns.hostname,
@@ -175,7 +191,7 @@ export function plan(options: TargetOptions): Record<string, unknown> {
: null,
dataStores: isExternalTarget(target)
? [
target.databaseMode === "external-active" ? "External PostgreSQL active from platform-db/PK01" : "External PostgreSQL pending from platform-db/Pika01",
isHostDockerTarget(target) ? `PK01 local PostgreSQL through ${database.host}:${database.port}` : target.databaseMode === "external-active" ? "External PostgreSQL active from platform-db/PK01" : "External PostgreSQL pending from platform-db/Pika01",
target.redisReplicas === 0 ? `${target.id} local Redis 8 ephemeral cache, scaled to zero until activation` : `${target.id} local Redis 8 ephemeral cache`,
]
: ["PostgreSQL 18", "Redis 8"],
@@ -226,7 +242,7 @@ export async function apply(config: UniDeskConfig, options: ApplyOptions): Promi
const job = startJob(
`platform_infra_sub2api_apply_${target.id.toLowerCase()}`,
["bun", "scripts/cli.ts", "platform-infra", "sub2api", "apply", "--target", target.id, "--confirm", "--wait"],
`Apply ${target.id} k3s platform-infra Sub2API manifests through the controlled UniDesk CLI`,
`Apply ${target.id} ${isHostDockerTarget(target) ? "host-Docker" : "k3s"} platform-infra Sub2API desired state through the controlled UniDesk CLI`,
);
return {
ok: true,
@@ -247,7 +263,7 @@ export async function apply(config: UniDeskConfig, options: ApplyOptions): Promi
};
}
if (options.dryRun) {
const result = await capture(config, target.route, ["sh"], dryRunScript(yaml, target));
const result = await capture(config, target.route, ["sh"], isHostDockerTarget(target) ? hostDockerDryRunScript(sub2api, target) : dryRunScript(yaml, target));
const parsed = parseJsonOutput(result.stdout);
return {
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
@@ -265,9 +281,16 @@ export async function apply(config: UniDeskConfig, options: ApplyOptions): Promi
const secretMaterial = target.databaseMode === "external-active" ? prepareExternalActiveSecret(sub2api) : null;
const publicExposureSecretMaterial = preparePublicExposureSecret(sub2api, target);
const egressProxySecretMaterial = prepareEgressProxySecret(sub2api, target);
const result = await capture(config, target.route, ["sh"], applyScript(sub2api, yaml, target, secretMaterial, publicExposureSecretMaterial, egressProxySecretMaterial));
const result = await capture(
config,
target.route,
["sh"],
isHostDockerTarget(target)
? hostDockerApplyScript(sub2api, target, secretMaterial)
: applyScript(sub2api, yaml, target, secretMaterial, publicExposureSecretMaterial, egressProxySecretMaterial),
);
const parsed = parseJsonOutput(result.stdout);
const pk01Exposure = publicExposureSecretMaterial === null ? null : await applyPk01PublicExposure(config, target);
const pk01Exposure = target.publicExposure?.enabled === true ? await applyPk01PublicExposure(config, target) : null;
return {
ok: result.exitCode === 0 && boolField(parsed, "ok", false) && (pk01Exposure === null || pk01Exposure.ok === true),
action: "platform-infra-sub2api-apply",
@@ -290,7 +313,7 @@ export async function apply(config: UniDeskConfig, options: ApplyOptions): Promi
export async function status(config: UniDeskConfig, options: DisclosureOptions): Promise<Record<string, unknown>> {
const sub2api = readSub2ApiConfig();
const target = resolveTarget(sub2api, options.targetId);
const result = await capture(config, target.route, ["sh"], statusScript(sub2api, target));
const result = await capture(config, target.route, ["sh"], isHostDockerTarget(target) ? hostDockerStatusScript(sub2api, target) : statusScript(sub2api, target));
const parsed = parseJsonOutput(result.stdout);
if (options.raw) {
return {
+10 -1
View File
@@ -24,7 +24,7 @@ export function renderPk01Caddyfile(target: Sub2ApiTargetConfig, exposure: Sub2A
sub2apiCaddyManagedMarker(target),
renderSimpleReverseProxyCaddySiteBlock({
hostname: exposure.dns.hostname,
upstream: `127.0.0.1:${exposure.frpc.remotePort}`,
upstream: publicExposureUpstream(target, exposure),
responseHeaderTimeoutSeconds: exposure.pk01.responseHeaderTimeoutSeconds,
}),
);
@@ -43,6 +43,15 @@ ${apiBlock.trim()}
`;
}
export function publicExposureUpstream(target: Sub2ApiTargetConfig, exposure: Sub2ApiPublicExposureConfig): string {
if (exposure.mode === "pk01-local") {
if (exposure.local === null) throw new Error(`publicExposure.local is required for target ${target.id} when mode=pk01-local`);
return `${exposure.local.upstreamHost}:${exposure.local.upstreamPort}`;
}
if (exposure.frpc === null) throw new Error(`publicExposure.frpc is required for target ${target.id} when mode=frp`);
return `127.0.0.1:${exposure.frpc.remotePort}`;
}
export function renderPk01CaddyService(exposure: Sub2ApiPublicExposureConfig): string {
return `[Unit]
Description=UniDesk PK01 Caddy edge for PikaPython and Sub2API
+69 -16
View File
@@ -15,7 +15,7 @@ import { capture, compactCapture, parseJsonOutput, prepareFrpcSecret, shQuote }
import { yamlBooleanField, yamlFieldLabel, yamlIntegerField } from "../platform-infra-ops-library";
import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets";
import type { Sub2ApiConfig, Sub2ApiDefaults, Sub2ApiEgressProxyConfig, Sub2ApiMasterShadowsocksConfig, Sub2ApiPublicExposureConfig, Sub2ApiTargetConfig } from "./entry";
import type { Sub2ApiConfig, Sub2ApiDefaults, Sub2ApiEgressProxyConfig, Sub2ApiHostDockerConfig, Sub2ApiMasterShadowsocksConfig, Sub2ApiPublicExposureConfig, Sub2ApiTargetConfig } from "./entry";
import { secretRoot } from "./apply-script";
import { configPath } from "./entry";
import { normalizePublicBaseUrl, validateHostOrIp, validateHostname, validateKubernetesResourceName, validatePort, validateProxyName, validateProxyUrl } from "./manifest";
@@ -162,6 +162,7 @@ export function parseTargets(root: Record<string, unknown>, defaultTargetId: str
const id = stringField(record, "id", path);
const route = stringField(record, "route", path);
const targetNamespace = stringField(record, "namespace", path);
const runtimeMode = record.runtimeMode === undefined ? "k3s" : enumField(record, "runtimeMode", path, ["k3s", "host-docker"] as const);
const role = stringField(record, "role", path);
const enabled = booleanField(record, "enabled", path);
const databaseMode = enumField(record, "databaseMode", path, ["bundled", "external-pending", "external-active"] as const);
@@ -170,6 +171,7 @@ export function parseTargets(root: Record<string, unknown>, defaultTargetId: str
const redisReplicas = integerField(record, "redisReplicas", path);
const image = targetImageOverride(record, path);
const dependencyImages = targetDependencyImageOverride(record, path);
const hostDocker = parseHostDockerConfig(record.hostDocker, path, runtimeMode);
const publicExposure = parsePublicExposureConfig(record.publicExposure, path);
const egressProxy = parseEgressProxyConfig(record.egressProxy, path);
if (!/^[A-Za-z0-9._-]+$/u.test(id)) throw new Error(`${configPath}.${path}.id must be a simple target id`);
@@ -177,7 +179,10 @@ export function parseTargets(root: Record<string, unknown>, defaultTargetId: str
if (!isKubernetesName(targetNamespace)) throw new Error(`${configPath}.${path}.namespace must be a Kubernetes namespace name`);
if (appReplicas < 0) throw new Error(`${configPath}.${path}.appReplicas must be >= 0`);
if (redisReplicas < 0) throw new Error(`${configPath}.${path}.redisReplicas must be >= 0`);
return { id, route, namespace: targetNamespace, role, enabled, databaseMode, redisMode, appReplicas, redisReplicas, image, dependencyImages, publicExposure, egressProxy };
if (runtimeMode === "host-docker" && databaseMode !== "external-active") throw new Error(`${configPath}.${path}.databaseMode must be external-active for runtimeMode=host-docker`);
if (runtimeMode === "host-docker" && egressProxy?.enabled === true) throw new Error(`${configPath}.${path}.egressProxy must be disabled or omitted for runtimeMode=host-docker`);
if (runtimeMode === "host-docker" && publicExposure?.enabled === true && publicExposure.mode !== "pk01-local") throw new Error(`${configPath}.${path}.publicExposure.mode must be pk01-local for runtimeMode=host-docker`);
return { id, route, namespace: targetNamespace, runtimeMode, role, enabled, databaseMode, redisMode, appReplicas, redisReplicas, image, dependencyImages, hostDocker, publicExposure, egressProxy };
});
const ids = new Set<string>();
for (const target of targets) {
@@ -188,6 +193,37 @@ export function parseTargets(root: Record<string, unknown>, defaultTargetId: str
return targets;
}
export function parseHostDockerConfig(value: unknown, path: string, runtimeMode: Sub2ApiTargetConfig["runtimeMode"]): Sub2ApiHostDockerConfig | null {
if (value === undefined || value === null) {
if (runtimeMode === "host-docker") throw new Error(`${configPath}.${path}.hostDocker is required when runtimeMode=host-docker`);
return null;
}
if (runtimeMode !== "host-docker") throw new Error(`${configPath}.${path}.hostDocker is only supported when runtimeMode=host-docker`);
if (typeof value !== "object" || Array.isArray(value)) throw new Error(`${configPath}.${path}.hostDocker must be an object`);
const record = value as Record<string, unknown>;
const hostDocker: Sub2ApiHostDockerConfig = {
projectName: stringField(record, "projectName", `${path}.hostDocker`),
workDir: stringField(record, "workDir", `${path}.hostDocker`),
composePath: stringField(record, "composePath", `${path}.hostDocker`),
envPath: stringField(record, "envPath", `${path}.hostDocker`),
appDataDir: stringField(record, "appDataDir", `${path}.hostDocker`),
appPort: integerField(record, "appPort", `${path}.hostDocker`),
redisPort: integerField(record, "redisPort", `${path}.hostDocker`),
databaseHost: stringField(record, "databaseHost", `${path}.hostDocker`),
databaseSslMode: stringField(record, "databaseSslMode", `${path}.hostDocker`),
noProxy: stringArrayField(record, "noProxy", `${path}.hostDocker`),
};
validateProxyName(hostDocker.projectName, `${path}.hostDocker.projectName`);
for (const key of ["workDir", "composePath", "envPath", "appDataDir"] as const) {
if (!hostDocker[key].startsWith("/")) throw new Error(`${configPath}.${path}.hostDocker.${key} must be absolute`);
}
validatePort(hostDocker.appPort, `${path}.hostDocker.appPort`);
validatePort(hostDocker.redisPort, `${path}.hostDocker.redisPort`);
validateHostOrIp(hostDocker.databaseHost, `${path}.hostDocker.databaseHost`);
if (!/^[A-Za-z0-9_-]+$/u.test(hostDocker.databaseSslMode)) throw new Error(`${configPath}.${path}.hostDocker.databaseSslMode has an unsupported format`);
return hostDocker;
}
export function targetImageOverride(record: Record<string, unknown>, path: string): Partial<Sub2ApiConfig["image"]> {
if (record.image === undefined) return {};
const image = objectField(record, "image", path);
@@ -230,22 +266,31 @@ export function parsePublicExposureConfig(value: unknown, path: string): Sub2Api
if (typeof value !== "object" || Array.isArray(value)) throw new Error(`${configPath}.${path}.publicExposure must be an object`);
const record = value as Record<string, unknown>;
const enabled = booleanField(record, "enabled", `${path}.publicExposure`);
const mode = record.mode === undefined ? "frp" : enumField(record, "mode", `${path}.publicExposure`, ["frp", "pk01-local"] as const);
const publicBaseUrl = stringField(record, "publicBaseUrl", `${path}.publicExposure`);
const dnsRaw = objectField(record, "dns", `${path}.publicExposure`);
const frpcRaw = objectField(record, "frpc", `${path}.publicExposure`);
const localRaw = record.local === undefined || record.local === null ? null : objectField(record, "local", `${path}.publicExposure`);
const frpcRaw = record.frpc === undefined || record.frpc === null ? null : objectField(record, "frpc", `${path}.publicExposure`);
const pk01Raw = objectField(record, "pk01", `${path}.publicExposure`);
const hostname = stringField(dnsRaw, "hostname", `${path}.publicExposure.dns`);
const expectedA = stringField(dnsRaw, "expectedA", `${path}.publicExposure.dns`);
const resolvers = stringArrayField(dnsRaw, "resolvers", `${path}.publicExposure.dns`);
if (mode === "frp" && frpcRaw === null) throw new Error(`${configPath}.${path}.publicExposure.frpc is required when mode=frp`);
if (mode === "pk01-local" && localRaw === null) throw new Error(`${configPath}.${path}.publicExposure.local is required when mode=pk01-local`);
const exposure: Sub2ApiPublicExposureConfig = {
enabled,
mode,
publicBaseUrl: normalizePublicBaseUrl(publicBaseUrl, `${path}.publicExposure.publicBaseUrl`),
dns: {
hostname,
expectedA,
resolvers,
},
frpc: {
local: localRaw === null ? null : {
upstreamHost: stringField(localRaw, "upstreamHost", `${path}.publicExposure.local`),
upstreamPort: integerField(localRaw, "upstreamPort", `${path}.publicExposure.local`),
},
frpc: frpcRaw === null ? null : {
deploymentName: stringField(frpcRaw, "deploymentName", `${path}.publicExposure.frpc`),
secretName: stringField(frpcRaw, "secretName", `${path}.publicExposure.frpc`),
secretKey: stringField(frpcRaw, "secretKey", `${path}.publicExposure.frpc`),
@@ -475,18 +520,26 @@ export function validatePublicExposureConfig(config: Sub2ApiPublicExposureConfig
for (const resolver of config.dns.resolvers) {
if (!/^[0-9.]+$/u.test(resolver)) throw new Error(`${configPath}.${path}.publicExposure.dns.resolvers entries must be IPv4 resolvers`);
}
validateKubernetesResourceName(config.frpc.deploymentName, `${path}.publicExposure.frpc.deploymentName`);
validateKubernetesResourceName(config.frpc.secretName, `${path}.publicExposure.frpc.secretName`);
if (config.frpc.secretKey !== "frpc.toml") throw new Error(`${configPath}.${path}.publicExposure.frpc.secretKey must be frpc.toml`);
if (!isImageReference(config.frpc.image)) throw new Error(`${configPath}.${path}.publicExposure.frpc.image has an unsupported format`);
validateHostOrIp(config.frpc.serverAddr, `${path}.publicExposure.frpc.serverAddr`);
validatePort(config.frpc.serverPort, `${path}.publicExposure.frpc.serverPort`);
validateProxyName(config.frpc.proxyName, `${path}.publicExposure.frpc.proxyName`);
validatePort(config.frpc.remotePort, `${path}.publicExposure.frpc.remotePort`);
validateHostOrIp(config.frpc.localIP, `${path}.publicExposure.frpc.localIP`);
validatePort(config.frpc.localPort, `${path}.publicExposure.frpc.localPort`);
if (!/^[A-Za-z0-9_./-]+$/u.test(config.frpc.tokenSourceRef)) throw new Error(`${configPath}.${path}.publicExposure.frpc.tokenSourceRef has an unsupported format`);
if (!/^[A-Z0-9_]+$/u.test(config.frpc.tokenSourceKey)) throw new Error(`${configPath}.${path}.publicExposure.frpc.tokenSourceKey must be an env key`);
if (config.mode === "frp") {
if (config.frpc === null) throw new Error(`${configPath}.${path}.publicExposure.frpc is required when mode=frp`);
validateKubernetesResourceName(config.frpc.deploymentName, `${path}.publicExposure.frpc.deploymentName`);
validateKubernetesResourceName(config.frpc.secretName, `${path}.publicExposure.frpc.secretName`);
if (config.frpc.secretKey !== "frpc.toml") throw new Error(`${configPath}.${path}.publicExposure.frpc.secretKey must be frpc.toml`);
if (!isImageReference(config.frpc.image)) throw new Error(`${configPath}.${path}.publicExposure.frpc.image has an unsupported format`);
validateHostOrIp(config.frpc.serverAddr, `${path}.publicExposure.frpc.serverAddr`);
validatePort(config.frpc.serverPort, `${path}.publicExposure.frpc.serverPort`);
validateProxyName(config.frpc.proxyName, `${path}.publicExposure.frpc.proxyName`);
validatePort(config.frpc.remotePort, `${path}.publicExposure.frpc.remotePort`);
validateHostOrIp(config.frpc.localIP, `${path}.publicExposure.frpc.localIP`);
validatePort(config.frpc.localPort, `${path}.publicExposure.frpc.localPort`);
if (!/^[A-Za-z0-9_./-]+$/u.test(config.frpc.tokenSourceRef)) throw new Error(`${configPath}.${path}.publicExposure.frpc.tokenSourceRef has an unsupported format`);
if (!/^[A-Z0-9_]+$/u.test(config.frpc.tokenSourceKey)) throw new Error(`${configPath}.${path}.publicExposure.frpc.tokenSourceKey must be an env key`);
}
if (config.mode === "pk01-local") {
if (config.local === null) throw new Error(`${configPath}.${path}.publicExposure.local is required when mode=pk01-local`);
validateHostOrIp(config.local.upstreamHost, `${path}.publicExposure.local.upstreamHost`);
validatePort(config.local.upstreamPort, `${path}.publicExposure.local.upstreamPort`);
}
if (!/^[A-Za-z0-9:_./-]+$/u.test(config.pk01.route)) throw new Error(`${configPath}.${path}.publicExposure.pk01.route has an unsupported format`);
if (!config.pk01.caddyBinaryPath.startsWith("/")) throw new Error(`${configPath}.${path}.publicExposure.pk01.caddyBinaryPath must be absolute`);
if (!/^https:\/\//u.test(config.pk01.caddyDownloadUrl)) throw new Error(`${configPath}.${path}.publicExposure.pk01.caddyDownloadUrl must use https://`);
+25 -2
View File
@@ -30,7 +30,8 @@ export const codexPoolConfigPath = rootPath("config", "platform-infra", "sub2api
export const legacySub2apiCaddyManagedMarker = "sub2api";
export function sub2apiCaddyManagedMarker(target: Sub2ApiTargetConfig): string {
return target.id.toUpperCase() === "D601" ? legacySub2apiCaddyManagedMarker : `${legacySub2apiCaddyManagedMarker}-${target.id.toLowerCase()}`;
const id = target.id.toUpperCase();
return id === "D601" || id === "PK01" ? legacySub2apiCaddyManagedMarker : `${legacySub2apiCaddyManagedMarker}-${target.id.toLowerCase()}`;
}
export const requiredSecretKeys = ["POSTGRES_PASSWORD", "ADMIN_PASSWORD", "JWT_SECRET", "TOTP_ENCRYPTION_KEY"] as const;
@@ -39,6 +40,8 @@ export type DatabaseMode = "bundled" | "external-pending" | "external-active";
export type RedisMode = "bundled-persistent" | "local-ephemeral";
export type RuntimeMode = "k3s" | "host-docker";
export interface Sub2ApiConfig {
version: number;
kind: string;
@@ -109,6 +112,7 @@ export interface Sub2ApiTargetConfig {
id: string;
route: string;
namespace: string;
runtimeMode: RuntimeMode;
role: string;
enabled: boolean;
databaseMode: DatabaseMode;
@@ -117,18 +121,37 @@ export interface Sub2ApiTargetConfig {
redisReplicas: number;
image: Partial<Sub2ApiConfig["image"]>;
dependencyImages: Partial<Sub2ApiConfig["dependencyImages"]>;
hostDocker: Sub2ApiHostDockerConfig | null;
publicExposure: Sub2ApiPublicExposureConfig | null;
egressProxy: Sub2ApiEgressProxyConfig | null;
}
export interface Sub2ApiHostDockerConfig {
projectName: string;
workDir: string;
composePath: string;
envPath: string;
appDataDir: string;
appPort: number;
redisPort: number;
databaseHost: string;
databaseSslMode: string;
noProxy: string[];
}
export interface Sub2ApiPublicExposureConfig {
enabled: boolean;
mode: "frp" | "pk01-local";
publicBaseUrl: string;
dns: {
hostname: string;
expectedA: string;
resolvers: string[];
};
local: {
upstreamHost: string;
upstreamPort: number;
} | null;
frpc: {
deploymentName: string;
secretName: string;
@@ -142,7 +165,7 @@ export interface Sub2ApiPublicExposureConfig {
localPort: number;
tokenSourceRef: string;
tokenSourceKey: string;
};
} | null;
pk01: {
route: string;
caddyBinaryPath: string;
+593
View File
@@ -0,0 +1,593 @@
import { shQuote } from "../platform-infra-public-service";
import type { ExternalActiveSecretMaterial, Sub2ApiConfig, Sub2ApiHostDockerConfig, Sub2ApiTargetConfig } from "./entry";
import { requiredSecretKeys } from "./entry";
import { imageRef, targetDatabase, targetDependencyImages, targetImage } from "./manifest";
export function requireHostDockerConfig(target: Sub2ApiTargetConfig): Sub2ApiHostDockerConfig {
if (target.hostDocker === null) throw new Error(`target ${target.id} requires hostDocker config when runtimeMode=host-docker`);
return target.hostDocker;
}
export function hostDockerPlanSummary(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): Record<string, unknown> {
const host = requireHostDockerConfig(target);
const database = targetDatabase(sub2api, target);
return {
runtimeMode: "host-docker",
route: target.route,
projectName: host.projectName,
workDir: host.workDir,
composePath: host.composePath,
envPath: host.envPath,
appDataDir: host.appDataDir,
appPort: host.appPort,
redisPort: host.redisPort,
databaseHost: database.host,
databasePort: database.port,
databaseSslMode: database.sslMode,
noProxy: host.noProxy,
valuesPrinted: false,
};
}
export function hostDockerComposeYaml(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
const host = requireHostDockerConfig(target);
const dependencyImages = targetDependencyImages(sub2api, target);
return `services:
redis:
image: ${yamlString(dependencyImages.redis)}
container_name: ${yamlString(`${host.projectName}-redis`)}
network_mode: host
restart: unless-stopped
command:
- redis-server
- --save
- ""
- --appendonly
- "no"
- --bind
- "127.0.0.1"
- --port
- ${yamlString(String(host.redisPort))}
app:
image: ${yamlString(imageRef(sub2api, target))}
container_name: ${yamlString(`${host.projectName}-app`)}
network_mode: host
restart: unless-stopped
env_file:
- ${yamlString(host.envPath)}
volumes:
- ${yamlString(`${host.appDataDir}:/app/data`)}
depends_on:
- redis
`;
}
export function hostDockerEnvFile(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig, secretMaterial: ExternalActiveSecretMaterial): string {
const host = requireHostDockerConfig(target);
const database = targetDatabase(sub2api, target);
const urlAllowlist = sub2api.security.urlAllowlist;
const values: Record<string, string> = {
AUTO_SETUP: "true",
SERVER_HOST: "127.0.0.1",
SERVER_PORT: String(host.appPort),
SERVER_MODE: "release",
RUN_MODE: "standard",
DATABASE_HOST: database.host,
DATABASE_PORT: String(database.port),
DATABASE_USER: database.user,
DATABASE_DBNAME: database.dbName,
DATABASE_SSLMODE: database.sslMode,
DATABASE_PASSWORD: secretMaterial.values.POSTGRES_PASSWORD,
DATABASE_MAX_OPEN_CONNS: "10",
DATABASE_MAX_IDLE_CONNS: "2",
DATABASE_CONN_MAX_LIFETIME_MINUTES: "30",
DATABASE_CONN_MAX_IDLE_TIME_MINUTES: "5",
REDIS_HOST: "127.0.0.1",
REDIS_PORT: String(host.redisPort),
REDIS_PASSWORD: "",
REDIS_DB: "0",
REDIS_POOL_SIZE: "32",
REDIS_MIN_IDLE_CONNS: "2",
REDIS_ENABLE_TLS: "false",
ADMIN_EMAIL: "admin@sub2api.platform-infra.local",
ADMIN_PASSWORD: secretMaterial.values.ADMIN_PASSWORD,
JWT_SECRET: secretMaterial.values.JWT_SECRET,
TOTP_ENCRYPTION_KEY: secretMaterial.values.TOTP_ENCRYPTION_KEY,
JWT_EXPIRE_HOUR: "24",
TZ: "Asia/Shanghai",
SECURITY_URL_ALLOWLIST_ENABLED: String(urlAllowlist.enabled),
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP: String(urlAllowlist.allowInsecureHttp),
SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS: String(urlAllowlist.allowPrivateHosts),
SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS: urlAllowlist.upstreamHosts.join(","),
UPDATE_PROXY_URL: "",
HTTP_PROXY: "",
HTTPS_PROXY: "",
ALL_PROXY: "",
http_proxy: "",
https_proxy: "",
all_proxy: "",
NO_PROXY: host.noProxy.join(","),
no_proxy: host.noProxy.join(","),
GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT: "0",
GATEWAY_OPENAI_HTTP2_ENABLED: "true",
GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1: "true",
GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD: "2",
GATEWAY_OPENAI_HTTP2_FALLBACK_WINDOW_SECONDS: "60",
GATEWAY_OPENAI_HTTP2_FALLBACK_TTL_SECONDS: "600",
GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT: "900",
GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL: "10",
GATEWAY_IMAGE_CONCURRENCY_ENABLED: "false",
GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS: "0",
GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE: "reject",
GATEWAY_IMAGE_CONCURRENCY_WAIT_TIMEOUT_SECONDS: "30",
GATEWAY_IMAGE_CONCURRENCY_MAX_WAITING_REQUESTS: "100",
};
return `${Object.entries(values).map(([key, value]) => `${key}=${envFileValue(value)}`).join("\n")}\n`;
}
export function hostDockerDryRunScript(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
const host = requireHostDockerConfig(target);
const compose = hostDockerComposeYaml(sub2api, target);
const composeB64 = Buffer.from(compose, "utf8").toString("base64");
return `
set -u
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
compose="$tmp/docker-compose.yml"
printf '%s' '${composeB64}' | base64 -d >"$compose"
docker_out="$tmp/docker.out"
docker_err="$tmp/docker.err"
compose_out="$tmp/compose.out"
compose_err="$tmp/compose.err"
ports_out="$tmp/ports.out"
ports_err="$tmp/ports.err"
docker_bin=""
if docker ps >/dev/null 2>&1; then
docker_bin="docker"
elif sudo docker ps >/dev/null 2>&1; then
docker_bin="sudo docker"
fi
if [ -n "$docker_bin" ]; then
$docker_bin ps --format '{{json .}}' >"$docker_out" 2>"$docker_err"
docker_rc=$?
if $docker_bin compose version >"$compose_out" 2>"$compose_err"; then
compose_rc=0
elif command -v docker-compose >/dev/null 2>&1 && docker-compose version >"$compose_out" 2>"$compose_err"; then
compose_rc=0
else
compose_rc=0
printf '%s\\n' 'docker compose is absent; apply will use raw docker run fallback' >"$compose_out"
fi
else
docker_rc=1
compose_rc=1
: >"$docker_out"
printf '%s\\n' 'docker daemon is not accessible' >"$docker_err"
: >"$compose_out"
printf '%s\\n' 'docker compose is not accessible' >"$compose_err"
fi
{
for port in ${host.appPort} ${host.redisPort}; do
state="free"
if command -v ss >/dev/null 2>&1 && ss -ltn | awk '{print $4}' | grep -E "(:|\\])$port$" >/dev/null 2>&1; then
state="listening"
fi
printf 'port=%s state=%s\\n' "$port" "$state"
done
} >"$ports_out" 2>"$ports_err"
ports_rc=$?
python3 - "$docker_rc" "$compose_rc" "$ports_rc" "$docker_out" "$docker_err" "$compose_out" "$compose_err" "$ports_out" "$ports_err" <<'PY'
import json
import sys
docker_rc, compose_rc, ports_rc = [int(value) for value in sys.argv[1:4]]
paths = sys.argv[4:]
def text(path, limit=4000):
try:
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
except FileNotFoundError:
return ""
payload = {
"ok": docker_rc == 0 and compose_rc == 0 and ports_rc == 0,
"target": "${target.id}",
"runtimeMode": "host-docker",
"route": "${target.route}",
"projectName": "${host.projectName}",
"composePath": "${host.composePath}",
"envPath": "${host.envPath}",
"appDataDir": "${host.appDataDir}",
"image": "${imageRef(sub2api, target)}",
"dependencyImages": ${JSON.stringify(targetDependencyImages(sub2api, target))},
"ports": {
"app": ${host.appPort},
"redis": ${host.redisPort},
"observed": text(paths[4], 2000),
},
"steps": {
"docker": {"exitCode": docker_rc, "stdout": text(paths[0]), "stderr": text(paths[1])},
"dockerCompose": {"exitCode": compose_rc, "stdout": text(paths[2]), "stderr": text(paths[3])},
"portObservation": {"exitCode": ports_rc, "stdout": text(paths[4]), "stderr": text(paths[5])},
},
"valuesPrinted": False,
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
`;
}
export function hostDockerApplyScript(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig, secretMaterial: ExternalActiveSecretMaterial | null): string {
if (secretMaterial === null) throw new Error("host-docker apply requires external-active secret material");
const host = requireHostDockerConfig(target);
const compose = hostDockerComposeYaml(sub2api, target);
const env = hostDockerEnvFile(sub2api, target, secretMaterial);
const composeB64 = Buffer.from(compose, "utf8").toString("base64");
const envB64 = Buffer.from(env, "utf8").toString("base64");
const image = targetImage(sub2api, target);
const dependencyImages = targetDependencyImages(sub2api, target);
const pullCommand = image.pullPolicy === "Always"
? `$docker_bin pull ${shQuote(imageRef(sub2api, target))} >"$pull_out" 2>"$pull_err"\napp_pull_rc=$?\n$docker_bin pull ${shQuote(dependencyImages.redis)} >>"$pull_out" 2>>"$pull_err"\nredis_pull_rc=$?\nif [ "$app_pull_rc" -eq 0 ] && [ "$redis_pull_rc" -eq 0 ]; then pull_rc=0; else pull_rc=1; fi`
: `: >"$pull_out"\n: >"$pull_err"\npull_rc=0`;
const secretSourceSummary = {
sourceRef: secretMaterial.sourceRef,
sourcePath: secretMaterial.sourcePath,
appSourceRef: secretMaterial.appSourceRef,
appSourcePath: secretMaterial.appSourcePath,
sourceAction: secretMaterial.action,
fingerprint: secretMaterial.fingerprint,
valuesPrinted: false,
};
return `
set -u
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
compose_tmp="$tmp/docker-compose.yml"
env_tmp="$tmp/sub2api.env"
printf '%s' '${composeB64}' | base64 -d >"$compose_tmp"
printf '%s' '${envB64}' | base64 -d >"$env_tmp"
docker_out="$tmp/docker.out"
docker_err="$tmp/docker.err"
install_out="$tmp/install.out"
install_err="$tmp/install.err"
pull_out="$tmp/pull.out"
pull_err="$tmp/pull.err"
up_out="$tmp/up.out"
up_err="$tmp/up.err"
health_out="$tmp/health.out"
health_err="$tmp/health.err"
docker_bin=""
if docker ps >/dev/null 2>&1; then
docker_bin="docker"
elif sudo docker ps >/dev/null 2>&1; then
docker_bin="sudo docker"
fi
if [ -n "$docker_bin" ]; then
if $docker_bin compose version >"$docker_out" 2>"$docker_err"; then
compose_cmd="$docker_bin compose"
compose_mode="plugin"
docker_rc=0
elif command -v docker-compose >/dev/null 2>&1 && docker-compose version >"$docker_out" 2>"$docker_err"; then
compose_cmd="docker-compose"
compose_mode="standalone"
docker_rc=0
else
compose_cmd=""
compose_mode="raw-docker"
$docker_bin version >"$docker_out" 2>"$docker_err"
docker_rc=$?
fi
else
compose_cmd=""
compose_mode="none"
docker_rc=1
: >"$docker_out"
printf '%s\\n' 'docker daemon or docker compose is not accessible' >"$docker_err"
fi
install_rc=1
if [ "$docker_rc" -eq 0 ]; then
sudo mkdir -p ${shQuote(host.workDir)} ${shQuote(host.appDataDir)} "$(dirname ${shQuote(host.composePath)})" "$(dirname ${shQuote(host.envPath)})" >"$install_out" 2>"$install_err"
install_rc=$?
if [ "$install_rc" -eq 0 ]; then
sudo install -m 0644 "$compose_tmp" ${shQuote(host.composePath)} >>"$install_out" 2>>"$install_err"
install_rc=$?
fi
if [ "$install_rc" -eq 0 ]; then
current_uid="$(id -u)"
current_gid="$(id -g)"
sudo install -m 0600 -o "$current_uid" -g "$current_gid" "$env_tmp" ${shQuote(host.envPath)} >>"$install_out" 2>>"$install_err"
install_rc=$?
fi
else
: >"$install_out"
: >"$install_err"
fi
if [ "$install_rc" -eq 0 ]; then
${pullCommand}
else
: >"$pull_out"
printf '%s\\n' 'skipped because install failed' >"$pull_err"
pull_rc=1
fi
if [ "$pull_rc" -eq 0 ]; then
if [ -n "$compose_cmd" ]; then
$compose_cmd -p ${shQuote(host.projectName)} -f ${shQuote(host.composePath)} up -d --remove-orphans >"$up_out" 2>"$up_err"
up_rc=$?
else
$docker_bin rm -f ${shQuote(`${host.projectName}-app`)} ${shQuote(`${host.projectName}-redis`)} >"$up_out" 2>"$up_err" || true
$docker_bin run -d --name ${shQuote(`${host.projectName}-redis`)} \\
--restart unless-stopped \\
--network host \\
${shQuote(dependencyImages.redis)} \\
redis-server --save "" --appendonly no --bind 127.0.0.1 --port ${host.redisPort} >>"$up_out" 2>>"$up_err"
redis_run_rc=$?
if [ "$redis_run_rc" -eq 0 ]; then
$docker_bin run -d --name ${shQuote(`${host.projectName}-app`)} \\
--restart unless-stopped \\
--network host \\
--env-file ${shQuote(host.envPath)} \\
-v ${shQuote(`${host.appDataDir}:/app/data`)} \\
${shQuote(imageRef(sub2api, target))} >>"$up_out" 2>>"$up_err"
app_run_rc=$?
else
app_run_rc=1
fi
if [ "$redis_run_rc" -eq 0 ] && [ "$app_run_rc" -eq 0 ]; then up_rc=0; else up_rc=1; fi
fi
else
: >"$up_out"
printf '%s\\n' 'skipped because pull failed' >"$up_err"
up_rc=1
fi
health_rc=1
if [ "$up_rc" -eq 0 ]; then
deadline=$(( $(date +%s) + 180 ))
while [ "$(date +%s)" -lt "$deadline" ]; do
if curl -fsS --max-time 5 http://127.0.0.1:${host.appPort}/health >"$health_out" 2>"$health_err"; then
health_rc=0
break
fi
sleep 3
done
else
: >"$health_out"
printf '%s\\n' 'skipped because compose up failed' >"$health_err"
fi
python3 - "$docker_rc" "$install_rc" "$pull_rc" "$up_rc" "$health_rc" "$compose_mode" "$docker_out" "$docker_err" "$install_out" "$install_err" "$pull_out" "$pull_err" "$up_out" "$up_err" "$health_out" "$health_err" <<'PY'
import json
import sys
docker_rc, install_rc, pull_rc, up_rc, health_rc = [int(value) for value in sys.argv[1:6]]
docker_mode = sys.argv[6]
paths = sys.argv[7:]
def text(path, limit=4000):
try:
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
except FileNotFoundError:
return ""
payload = {
"ok": docker_rc == 0 and install_rc == 0 and pull_rc == 0 and up_rc == 0 and health_rc == 0,
"target": "${target.id}",
"runtimeMode": "host-docker",
"route": "${target.route}",
"projectName": "${host.projectName}",
"dockerMode": docker_mode,
"composePath": "${host.composePath}",
"envPath": "${host.envPath}",
"appDataDir": "${host.appDataDir}",
"image": "${imageRef(sub2api, target)}",
"secret": {
"requiredKeys": ${JSON.stringify(requiredSecretKeys)},
"externalActiveSource": json.loads(${JSON.stringify(JSON.stringify(secretSourceSummary))}),
"valuesPrinted": False,
},
"steps": {
"docker": {"exitCode": docker_rc, "stdout": text(paths[0]), "stderr": text(paths[1])},
"installFiles": {"exitCode": install_rc, "stdout": text(paths[2]), "stderr": text(paths[3])},
"pull": {"exitCode": pull_rc, "stdout": text(paths[4]), "stderr": text(paths[5])},
"composeUp": {"exitCode": up_rc, "stdout": text(paths[6]), "stderr": text(paths[7])},
"localHealth": {"exitCode": health_rc, "stdout": text(paths[8]), "stderr": text(paths[9])},
},
"valuesPrinted": False,
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
`;
}
export function hostDockerStatusScript(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
const host = requireHostDockerConfig(target);
return `
set -u
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
docker_out="$tmp/docker.out"
docker_err="$tmp/docker.err"
ps_out="$tmp/ps.out"
ps_err="$tmp/ps.err"
health_out="$tmp/health.out"
health_err="$tmp/health.err"
caddy_out="$tmp/caddy.out"
caddy_err="$tmp/caddy.err"
docker_bin=""
if docker ps >/dev/null 2>&1; then
docker_bin="docker"
elif sudo docker ps >/dev/null 2>&1; then
docker_bin="sudo docker"
fi
if [ -n "$docker_bin" ]; then
$docker_bin version --format '{{json .}}' >"$docker_out" 2>"$docker_err"
docker_rc=$?
$docker_bin ps -a --filter label=com.docker.compose.project=${shQuote(host.projectName)} --format '{{json .}}' >"$ps_out" 2>"$ps_err"
$docker_bin ps -a --filter name=${shQuote(`${host.projectName}-`)} --format '{{json .}}' >>"$ps_out" 2>>"$ps_err"
ps_rc=$?
else
docker_rc=1
ps_rc=1
: >"$docker_out"
printf '%s\\n' 'docker daemon is not accessible' >"$docker_err"
: >"$ps_out"
: >"$ps_err"
fi
curl -fsS --max-time 5 http://127.0.0.1:${host.appPort}/health >"$health_out" 2>"$health_err"
health_rc=$?
systemctl is-active caddy >"$caddy_out" 2>"$caddy_err"
caddy_rc=$?
python3 - "$docker_rc" "$ps_rc" "$health_rc" "$caddy_rc" "$docker_out" "$docker_err" "$ps_out" "$ps_err" "$health_out" "$health_err" "$caddy_out" "$caddy_err" <<'PY'
import json
import sys
docker_rc, ps_rc, health_rc, caddy_rc = [int(value) for value in sys.argv[1:5]]
paths = sys.argv[5:]
def text(path, limit=4000):
try:
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
except FileNotFoundError:
return ""
containers = []
for line in text(paths[2], 20000).splitlines():
try:
containers.append(json.loads(line))
except json.JSONDecodeError:
pass
payload = {
"ok": docker_rc == 0 and ps_rc == 0 and health_rc == 0,
"target": "${target.id}",
"runtimeMode": "host-docker",
"route": "${target.route}",
"projectName": "${host.projectName}",
"serviceDns": "host-docker:127.0.0.1:${host.appPort}",
"containers": containers,
"secret": {"managedByThisApply": True, "valuesPrinted": False},
"networkPolicy": {"ok": True, "mode": "not-applicable-host-docker"},
"egressProxy": {"enabled": False},
"sentinel": {"enabled": False, "mode": "not-deployed-host-docker"},
"checks": {
"docker": {"exitCode": docker_rc, "stdout": text(paths[0]), "stderr": text(paths[1])},
"composeContainers": {"exitCode": ps_rc, "stdout": text(paths[2], 8000), "stderr": text(paths[3])},
"localHealth": {"exitCode": health_rc, "stdout": text(paths[4]), "stderr": text(paths[5])},
"caddyService": {"exitCode": caddy_rc, "stdout": text(paths[6]), "stderr": text(paths[7])},
},
"valuesPrinted": False,
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
`;
}
export function hostDockerValidateScript(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
const host = requireHostDockerConfig(target);
const database = targetDatabase(sub2api, target);
const exposure = target.publicExposure?.enabled ? target.publicExposure : null;
const publicProbe = exposure === null ? `
public_rc=0
: >"$tmp/public.out"
: >"$tmp/public.err"
` : `
curl -kfsS --max-time 20 --resolve ${shQuote(`${exposure.dns.hostname}:443:127.0.0.1`)} ${shQuote(`${exposure.publicBaseUrl}/health`)} >"$tmp/public.out" 2>"$tmp/public.err"
public_rc=$?
`;
return `
set -u
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
docker_out="$tmp/docker.out"
docker_err="$tmp/docker.err"
app_out="$tmp/app.out"
app_err="$tmp/app.err"
redis_out="$tmp/redis.out"
redis_err="$tmp/redis.err"
pg_out="$tmp/pg.out"
pg_err="$tmp/pg.err"
health_out="$tmp/health.out"
health_err="$tmp/health.err"
docker_bin=""
if docker ps >/dev/null 2>&1; then
docker_bin="docker"
elif sudo docker ps >/dev/null 2>&1; then
docker_bin="sudo docker"
fi
if [ -n "$docker_bin" ]; then
$docker_bin inspect -f '{{.State.Running}}' ${shQuote(`${host.projectName}-app`)} >"$app_out" 2>"$app_err"
app_rc=$?
$docker_bin exec ${shQuote(`${host.projectName}-redis`)} redis-cli -h 127.0.0.1 -p ${host.redisPort} ping >"$redis_out" 2>"$redis_err"
redis_rc=$?
$docker_bin ps --filter label=com.docker.compose.project=${shQuote(host.projectName)} --format '{{json .}}' >"$docker_out" 2>"$docker_err"
$docker_bin ps --filter name=${shQuote(`${host.projectName}-`)} --format '{{json .}}' >>"$docker_out" 2>>"$docker_err"
docker_rc=$?
else
app_rc=1
redis_rc=1
docker_rc=1
: >"$app_out"
: >"$redis_out"
: >"$docker_out"
printf '%s\\n' 'docker daemon is not accessible' >"$app_err"
printf '%s\\n' 'docker daemon is not accessible' >"$redis_err"
printf '%s\\n' 'docker daemon is not accessible' >"$docker_err"
fi
if command -v pg_isready >/dev/null 2>&1; then
pg_isready -h ${shQuote(database.host)} -p ${database.port} -U ${shQuote(database.user)} -d ${shQuote(database.dbName)} >"$pg_out" 2>"$pg_err"
pg_rc=$?
else
pg_rc=127
: >"$pg_out"
printf '%s\\n' 'pg_isready is not installed on PK01 host' >"$pg_err"
fi
curl -fsS --max-time 10 http://127.0.0.1:${host.appPort}/health >"$health_out" 2>"$health_err"
health_rc=$?
${publicProbe}
python3 - "$docker_rc" "$app_rc" "$redis_rc" "$pg_rc" "$health_rc" "$public_rc" "$docker_out" "$docker_err" "$app_out" "$app_err" "$redis_out" "$redis_err" "$pg_out" "$pg_err" "$health_out" "$health_err" "$tmp/public.out" "$tmp/public.err" <<'PY'
import json
import sys
docker_rc, app_rc, redis_rc, pg_rc, health_rc, public_rc = [int(value) for value in sys.argv[1:7]]
paths = sys.argv[7:]
def text(path, limit=4000):
try:
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
except FileNotFoundError:
return ""
payload = {
"ok": docker_rc == 0 and app_rc == 0 and redis_rc == 0 and pg_rc == 0 and health_rc == 0 and public_rc == 0,
"target": "${target.id}",
"runtimeMode": "host-docker",
"route": "${target.route}",
"serviceDns": "host-docker:127.0.0.1:${host.appPort}",
"checks": {
"dockerComposeProject": {"exitCode": docker_rc, "stdout": text(paths[0], 8000), "stderr": text(paths[1])},
"appContainerRunning": {"exitCode": app_rc, "stdout": text(paths[2]), "stderr": text(paths[3])},
"redisPing": {"exitCode": redis_rc, "stdout": text(paths[4]), "stderr": text(paths[5])},
"postgresLocalPgIsReady": {"exitCode": pg_rc, "host": "${database.host}", "port": ${database.port}, "user": "${database.user}", "dbName": "${database.dbName}", "stdout": text(paths[6]), "stderr": text(paths[7])},
"sub2apiLocalHealth": {"exitCode": health_rc, "stdout": text(paths[8]), "stderr": text(paths[9])},
"sub2apiPublicHealthViaPk01Caddy": {"exitCode": public_rc, "stdout": text(paths[10]), "stderr": text(paths[11])},
"sentinel": {"ok": True, "mode": "not-deployed-host-docker"},
"egressProxy": {"ok": True, "mode": "not-deployed-host-docker"},
},
"valuesPrinted": False,
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
`;
}
function yamlString(value: string): string {
return JSON.stringify(value);
}
function envFileValue(value: string): string {
if (/^[A-Za-z0-9_./:@,+-]*$/u.test(value)) return value;
return JSON.stringify(value);
}
+22 -5
View File
@@ -14,7 +14,7 @@ import { capture, compactCapture, parseJsonOutput, prepareFrpcSecret, shQuote }
import { yamlBooleanField, yamlFieldLabel, yamlIntegerField } from "../platform-infra-ops-library";
import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets";
import type { ManagedResourceCleanupPlan, Sub2ApiConfig, Sub2ApiEgressProxyConfig, Sub2ApiTargetConfig } from "./entry";
import type { ExternalDatabaseConfig, ManagedResourceCleanupPlan, Sub2ApiConfig, Sub2ApiEgressProxyConfig, Sub2ApiTargetConfig } from "./entry";
import { isKubernetesName, stringField } from "./config";
import { codexPoolConfigPath, configPath, manifestPath } from "./entry";
@@ -95,7 +95,22 @@ export function isExternalTarget(target: Sub2ApiTargetConfig): boolean {
return target.databaseMode === "external-pending" || target.databaseMode === "external-active";
}
export function isHostDockerTarget(target: Sub2ApiTargetConfig): boolean {
return target.runtimeMode === "host-docker";
}
export function targetDatabase(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): ExternalDatabaseConfig {
const hostDocker = target.hostDocker;
if (hostDocker === null) return sub2api.runtime.database;
return {
...sub2api.runtime.database,
host: hostDocker.databaseHost,
sslMode: hostDocker.databaseSslMode,
};
}
export function manifest(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
if (isHostDockerTarget(target)) return "";
if (isExternalTarget(target)) return externalPendingManifest(sub2api, target);
const template = readFileSync(manifestPath, "utf8");
const urlAllowlist = sub2api.security.urlAllowlist;
@@ -143,10 +158,10 @@ export function managedResourceCleanupPlan(sub2api: Sub2ApiConfig, target: Sub2A
...sub2api.defaults.cleanup.redisPersistentState,
},
publicExposure: {
enabled: target.publicExposure?.enabled === true,
deploymentName: target.publicExposure?.frpc.deploymentName ?? sub2api.defaults.cleanup.publicExposure.deploymentName,
enabled: target.publicExposure?.enabled === true && target.publicExposure.mode === "frp",
deploymentName: target.publicExposure?.frpc?.deploymentName ?? sub2api.defaults.cleanup.publicExposure.deploymentName,
configMapName: sub2api.defaults.cleanup.publicExposure.configMapName,
secretName: target.publicExposure?.frpc.secretName ?? sub2api.defaults.cleanup.publicExposure.secretName,
secretName: target.publicExposure?.frpc?.secretName ?? sub2api.defaults.cleanup.publicExposure.secretName,
},
egressProxy: {
enabled: target.egressProxy?.enabled === true,
@@ -187,7 +202,7 @@ export function codexPoolSentinelResourceNames(): ManagedResourceCleanupPlan["se
export function externalPendingManifest(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
const urlAllowlist = sub2api.security.urlAllowlist;
const database = sub2api.runtime.database;
const database = targetDatabase(sub2api, target);
const databaseStatus = target.databaseMode === "external-active" ? "external-db-active" : "pending-external-db";
const redisService = sub2api.runtime.redis.serviceName;
const appReplicas = target.appReplicas;
@@ -522,6 +537,8 @@ export function sub2ApiProxyEnv(target: Sub2ApiTargetConfig): { httpProxy: strin
export function renderPublicExposureManifest(target: Sub2ApiTargetConfig): string {
const exposure = target.publicExposure;
if (exposure === null || !exposure.enabled) return "";
if (exposure.mode === "pk01-local") return "";
if (exposure.frpc === null) throw new Error(`publicExposure.frpc is required for target ${target.id} when mode=frp`);
return `---
apiVersion: apps/v1
kind: Deployment
+30 -9
View File
@@ -17,7 +17,7 @@ import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile,
import { apply, plan, status } from "./actions";
import { defaultSub2ApiTargetId, readSub2ApiConfig, validateOptions } from "./config";
import { configPath, platformInfraHelp, serviceName } from "./entry";
import { resolveTarget } from "./manifest";
import { isHostDockerTarget, resolveTarget } from "./manifest";
import { validate } from "./policy";
export function sub2ApiHelpTargetSummary(): Record<string, unknown> {
@@ -27,7 +27,8 @@ export function sub2ApiHelpTargetSummary(): Record<string, unknown> {
default: sub2api.defaults.targetId,
namespace: target.namespace,
service: serviceName,
serviceDns: `${serviceName}.${target.namespace}.svc.cluster.local:8080`,
runtimeMode: target.runtimeMode,
serviceDns: isHostDockerTarget(target) ? `host-docker:127.0.0.1:${target.hostDocker?.appPort ?? 0}` : `${serviceName}.${target.namespace}.svc.cluster.local:8080`,
exposure: target.publicExposure?.enabled === true ? "yaml-controlled-public-exposure" : "k3s-cluster-internal-only",
resourceLimits: "unset-by-policy",
versionConfigPath: configPath,
@@ -145,6 +146,8 @@ function renderSub2ApiStatus(result: Record<string, unknown>): RenderedCliResult
const secret = record(summary.secret);
const egress = record(summary.egressProxy);
const networkPolicy = record(summary.networkPolicy);
const checks = record(summary.checks);
const isHostDocker = stringValue(summary.runtimeMode, "") === "host-docker";
const deployments = arrayRecords(summary.deployments)
.filter((item) => stringValue(item.name).startsWith("sub2api"))
.map((item) => [
@@ -153,6 +156,13 @@ function renderSub2ApiStatus(result: Record<string, unknown>): RenderedCliResult
`${stringValue(item.readyReplicas, "0")}/${stringValue(item.desired, "0")}`,
arrayValues(item.images).slice(0, 1).map((value) => stringValue(value)).join(",") || "-",
]);
const containers = arrayRecords(summary.containers)
.map((item) => [
stringValue(item.Names, stringValue(item.names)),
stringValue(item.State, stringValue(item.state)),
stringValue(item.Status, stringValue(item.status)),
stringValue(item.Image, stringValue(item.image)),
]);
const services = arrayRecords(summary.services)
.filter((item) => stringValue(item.name).startsWith("sub2api"))
.map((item) => [
@@ -165,18 +175,29 @@ function renderSub2ApiStatus(result: Record<string, unknown>): RenderedCliResult
...table(["TARGET", "ROUTE", "NAMESPACE", "STATUS"], [[stringValue(summary.target, stringValue(target.id)), stringValue(summary.route, stringValue(target.route)), stringValue(summary.namespace, stringValue(target.namespace)), stringValue(summary.status, result.ok === false ? "failed" : "ok")]]),
"",
"WORKLOADS",
...(deployments.length === 0 ? ["-"] : table(["NAME", "READY", "REPLICAS", "IMAGE"], deployments)),
...(deployments.length > 0
? table(["NAME", "READY", "REPLICAS", "IMAGE"], deployments)
: containers.length > 0
? table(["NAME", "STATE", "STATUS", "IMAGE"], containers)
: ["-"]),
"",
"SERVICES",
...(services.length === 0 ? ["-"] : table(["NAME", "TYPE", "PORTS"], services)),
"",
"CHECKS",
...table(["CHECK", "VALUE", "DETAIL"], [
["namespace", boolText(summary.namespaceExists), stringValue(summary.namespace)],
["secret", boolText(secret.ready), `name=${stringValue(secret.name, "-")} missing=${arrayValues(secret.missingKeys).length}`],
["egressProxy", boolText(egress.aligned), `enabled=${boolText(egress.enabled)} valuesPrinted=false`],
["networkPolicy", boolText(networkPolicy.ok), `required=${stringValue(networkPolicy.requiredName, "-")}`],
]),
...table(["CHECK", "VALUE", "DETAIL"], isHostDocker
? [
["localHealth", boolText(record(checks.localHealth).exitCode === 0), "http://127.0.0.1 health"],
["caddy", boolText(record(checks.caddyService).exitCode === 0), "PK01 Caddy service"],
["egressProxy", boolText(egress.enabled === false), "not deployed"],
["sentinel", boolText(record(summary.sentinel).enabled === false), "not deployed"],
]
: [
["namespace", boolText(summary.namespaceExists), stringValue(summary.namespace)],
["secret", boolText(secret.ready), `name=${stringValue(secret.name, "-")} missing=${arrayValues(secret.missingKeys).length}`],
["egressProxy", boolText(egress.aligned), `enabled=${boolText(egress.enabled)} valuesPrinted=false`],
["networkPolicy", boolText(networkPolicy.ok), `required=${stringValue(networkPolicy.requiredName, "-")}`],
]),
"",
"NEXT",
` validate: bun scripts/cli.ts platform-infra sub2api validate --target ${stringValue(summary.target, stringValue(target.id))}`,
@@ -16,7 +16,7 @@ import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile,
import type { EgressProxySubscriptionCandidateSummary, EgressProxySubscriptionDiagnostics, Sub2ApiEgressProxyConfig, Sub2ApiPublicExposureConfig, Sub2ApiTargetConfig } from "./entry";
import { status } from "./actions";
import { renderPk01CaddyService, renderPk01Caddyfile } from "./apply-script";
import { publicExposureUpstream, renderPk01CaddyService, renderPk01Caddyfile } from "./apply-script";
import { asRecordOrNull, boolField } from "./utils";
export type SubscriptionNode =
@@ -436,6 +436,18 @@ if [ -f "$download_cache" ]; then stat -c 'cache_bytes=%s cache_mtime=%y' "$down
export function pk01PublicExposureScript(target: Sub2ApiTargetConfig, exposure: Sub2ApiPublicExposureConfig): string {
const caddyfile = renderPk01Caddyfile(target, exposure);
const serviceUnit = renderPk01CaddyService(exposure);
const exposureMode = exposure.mode === "pk01-local" ? "pk01-caddy-local-docker" : "pk01-caddy-frp-direct";
const dataPath = exposure.mode === "pk01-local"
? `client -> PK01 Caddy -> ${publicExposureUpstream(target, exposure)} -> PK01 Docker Sub2API`
: `client -> PK01 Caddy -> PK01 frps remotePort -> ${target.id} frpc -> Sub2API`;
const frpSummary = exposure.mode === "frp" && exposure.frpc !== null
? {
server: `${exposure.frpc.serverAddr}:${exposure.frpc.serverPort}`,
remotePort: exposure.frpc.remotePort,
proxyName: exposure.frpc.proxyName,
}
: null;
const frpSummaryPython = frpSummary === null ? "None" : `json.loads(${JSON.stringify(JSON.stringify(frpSummary))})`;
const caddyfileB64 = Buffer.from(caddyfile, "utf8").toString("base64");
const serviceUnitB64 = Buffer.from(serviceUnit, "utf8").toString("base64");
const caddyDownloadProxyArgs = exposure.pk01.caddyDownloadProxyUrl === null ? "" : `--proxy ${shQuote(exposure.pk01.caddyDownloadProxyUrl)}`;
@@ -606,11 +618,11 @@ def text(path, limit=4000):
payload = {
"ok": download_rc == 0 and install_rc == 0 and validate_rc == 0 and pikanode_rc == 0 and caddy_rc == 0,
"target": "${target.id}",
"mode": "pk01-caddy-frp-direct",
"mode": ${JSON.stringify(exposureMode)},
"publicBaseUrl": "${exposure.publicBaseUrl}",
"hostname": "${exposure.dns.hostname}",
"expectedA": "${exposure.dns.expectedA}",
"dataPath": "client -> PK01 Caddy -> PK01 frps remotePort -> ${target.id} frpc -> Sub2API",
"dataPath": ${JSON.stringify(dataPath)},
"pikanodeRole": "pikapython.com upstream only; ${exposure.dns.hostname} does not pass through pikanode Express",
"caddy": {
"binaryPath": "${exposure.pk01.caddyBinaryPath}",
@@ -618,11 +630,7 @@ payload = {
"serviceName": "${exposure.pk01.caddyServiceName}",
"downloadProxy": {"mode": download_proxy_mode, "url": download_proxy_url or None},
},
"frp": {
"server": "${exposure.frpc.serverAddr}:${exposure.frpc.serverPort}",
"remotePort": ${exposure.frpc.remotePort},
"proxyName": "${exposure.frpc.proxyName}",
},
"frp": ${frpSummaryPython},
"steps": {
"downloadCaddy": {"exitCode": download_rc, "action": download_action, "stdout": text(paths[0]), "stderr": text(paths[1])},
"installConfig": {"exitCode": install_rc, "stdout": text(paths[2]), "stderr": text(paths[3])},
+34 -2
View File
@@ -18,7 +18,8 @@ import type { Sub2ApiConfig, Sub2ApiTargetConfig } from "./entry";
import type { DisclosureOptions, PolicyCheck } from "./options";
import { readSub2ApiConfig } from "./config";
import { serviceName } from "./entry";
import { isExternalTarget, resolveTarget } from "./manifest";
import { hostDockerValidateScript } from "./host-docker";
import { isExternalTarget, isHostDockerTarget, resolveTarget, targetHasSentinel } from "./manifest";
import { escapeRegExp, hasAllowAllNetworkPolicy, hasDeploymentReplicas } from "./secrets-and-egress";
import { boolField, validateExternalPendingScript } from "./utils";
import { validateExternalActiveScript, validateScript } from "./validate-script";
@@ -26,7 +27,9 @@ import { validateExternalActiveScript, validateScript } from "./validate-script"
export async function validate(config: UniDeskConfig, options: DisclosureOptions): Promise<Record<string, unknown>> {
const sub2api = readSub2ApiConfig();
const target = resolveTarget(sub2api, options.targetId);
const script = target.databaseMode === "external-pending"
const script = isHostDockerTarget(target)
? hostDockerValidateScript(sub2api, target)
: target.databaseMode === "external-pending"
? validateExternalPendingScript(sub2api, target)
: target.databaseMode === "external-active"
? validateExternalActiveScript(sub2api, target)
@@ -60,6 +63,35 @@ export async function validate(config: UniDeskConfig, options: DisclosureOptions
}
export function policyChecks(sub2api: Sub2ApiConfig, yaml: string, target: Sub2ApiTargetConfig): PolicyCheck[] {
if (isHostDockerTarget(target)) {
return [
{
name: "host-docker-no-kubernetes-manifest",
ok: yaml.trim().length === 0,
detail: "PK01 host-Docker Sub2API must not render or apply Kubernetes manifests.",
},
{
name: "host-docker-config-present",
ok: target.hostDocker !== null,
detail: "PK01 host-Docker Sub2API must be fully declared in config/platform-infra/sub2api.yaml.",
},
{
name: "host-docker-no-egress-proxy",
ok: target.egressProxy === null || !target.egressProxy.enabled,
detail: "PK01 Sub2API exits directly from PK01/api.pikapython.com and does not deploy the D601 egress proxy.",
},
{
name: "host-docker-no-sentinel",
ok: !targetHasSentinel(sub2api, target),
detail: "PK01 bare Docker deployment must not deploy the codex-pool sentinel.",
},
{
name: "host-docker-public-exposure-local",
ok: target.publicExposure?.enabled === true && target.publicExposure.mode === "pk01-local",
detail: "PK01 public exposure must be Caddy to local Docker, not FRP.",
},
];
}
const cleanup = sub2api.defaults.cleanup;
const redisService = sub2api.runtime.redis.serviceName;
const checks: PolicyCheck[] = [
@@ -95,6 +95,8 @@ export function prepareExternalActiveSecret(sub2api: Sub2ApiConfig): ExternalAct
export function preparePublicExposureSecret(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): PublicExposureSecretMaterial | null {
const exposure = target.publicExposure;
if (exposure === null || !exposure.enabled) return null;
if (exposure.mode === "pk01-local") return null;
if (exposure.frpc === null) throw new Error(`publicExposure.frpc is required for target ${target.id} when mode=frp`);
return prepareFrpcSecret({
secretRoot: secretRoot(sub2api),
exposure,