Files
pikasTech-unidesk/scripts/src/platform-infra/actions.ts
T
2026-06-30 10:38:03 +00:00

380 lines
18 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. actions module for scripts/src/platform-infra.ts.
// Moved mechanically from scripts/src/platform-infra.ts:1588-1852 for #903.
import { createHash, randomBytes } from "node:crypto";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute, join } from "node:path";
import type { UniDeskConfig } from "../config";
import { rootPath } from "../config";
import { startJob } from "../jobs";
import type { RenderedCliResult } from "../output";
import { pk01CaddyMergeManagedBlocksPython, renderCaddyManagedBlock, renderSimpleReverseProxyCaddySiteBlock } from "../pk01-caddy";
import { capture, compactCapture, parseJsonOutput, prepareFrpcSecret, shQuote } from "../platform-infra-public-service";
import { yamlBooleanField, yamlFieldLabel, yamlIntegerField } from "../platform-infra-ops-library";
import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets";
import type { Sub2ApiAccountLocalProxyConfig, Sub2ApiEgressProxyConfig, Sub2ApiPublicExposureConfig } from "./entry";
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 { 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 { prepareAccountLocalProxySecret, prepareEgressProxySecret, prepareExternalActiveSecret, preparePublicExposureSecret } from "./secrets-and-egress";
import { statusScript } from "./status-script";
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,
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",
caddyBinaryPath: exposure.pk01.caddyBinaryPath,
caddyDownloadProxyUrl: exposure.pk01.caddyDownloadProxyUrl,
caddyConfigPath: exposure.pk01.caddyConfigPath,
caddyServiceName: exposure.pk01.caddyServiceName,
pikanodeHttpHostPort: exposure.pk01.pikanodeHttpHostPort,
},
};
}
export function egressProxySummary(proxy: Sub2ApiEgressProxyConfig): Record<string, unknown> {
return {
enabled: proxy.enabled,
mode: proxy.sourceType === "master-shadowsocks" ? "master-shadowsocks-http-proxy" : "master-vpn-subscription-http-proxy",
deploymentName: proxy.deploymentName,
serviceName: proxy.serviceName,
secretName: proxy.secretName,
image: proxy.image,
imagePullPolicy: proxy.imagePullPolicy,
listenPort: proxy.listenPort,
hostNetwork: proxy.hostNetwork,
sourceConfigRef: proxy.sourceConfigRef,
sourceFingerprint: proxy.sourceFingerprint,
sourceRef: proxy.sourceRef,
sourceType: proxy.sourceType,
preferredOutbound: proxy.preferredOutbound,
masterShadowsocks: proxy.masterShadowsocks === null
? null
: {
serverHost: proxy.masterShadowsocks.serverHost,
serverPort: proxy.masterShadowsocks.serverPort,
method: proxy.masterShadowsocks.method,
},
applyToSub2Api: proxy.applyToSub2Api,
applyToSentinel: proxy.applyToSentinel,
healthProbeUrl: proxy.healthProbeUrl,
noProxy: proxy.noProxy,
valuesPrinted: false,
};
}
export function accountLocalProxySummary(proxy: Sub2ApiAccountLocalProxyConfig): Record<string, unknown> {
return {
enabled: proxy.enabled,
mode: "sub2api-pod-loopback-account-proxy",
containerName: proxy.containerName,
secretName: proxy.secretName,
image: proxy.image,
imagePullPolicy: proxy.imagePullPolicy,
listen: `${proxy.listenHost}:${proxy.listenPort}`,
sourceConfigRef: proxy.sourceConfigRef,
sourceFingerprint: proxy.sourceFingerprint,
sourceRef: proxy.sourceRef,
sourceType: proxy.sourceType,
healthProbeUrl: proxy.healthProbeUrl,
valuesPrinted: false,
};
}
export function plan(options: TargetOptions): Record<string, unknown> {
const sub2api = readSub2ApiConfig();
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",
target: {
id: target.id,
route: target.route,
namespace: target.namespace,
runtimeMode: target.runtimeMode,
role: target.role,
manifestPath,
configPath,
fieldManager,
serviceDns: isHostDockerTarget(target) ? `host-docker:127.0.0.1:${target.hostDocker?.appPort ?? 0}` : `${serviceName}.${target.namespace}.svc.cluster.local:8080`,
},
config: {
path: configPath,
version: sub2api.version,
kind: sub2api.kind,
metadata: sub2api.metadata,
image: imageRef(sub2api, target),
pullPolicy: targetImage(sub2api, target).pullPolicy,
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),
accountLocalProxy: target.accountLocalProxy === null ? null : accountLocalProxySummary(target.accountLocalProxy),
},
externalDatabase: isExternalTarget(target) ? {
status: target.databaseMode === "external-active" ? "external-db-active" : "pending-external-db",
sourceRef: sub2api.runtime.database.sourceRef,
sourceKeys: sub2api.runtime.database.sourceKeys,
secretName: sub2api.runtime.database.secretName,
passwordKey: sub2api.runtime.database.passwordKey,
host: database.host,
port: database.port,
user: database.user,
dbName: database.dbName,
sslMode: database.sslMode,
pendingAllowed: database.pendingAllowed,
} : null,
},
decision: {
owner: "UniDesk",
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
? 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: 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: 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: 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,
expectedA: target.publicExposure.dns.expectedA,
}
: null,
egressProxy: target.egressProxy?.enabled
? {
mode: `${target.id} in-cluster HTTP proxy client to ${target.egressProxy.sourceType}`,
service: `${target.egressProxy.serviceName}.${target.namespace}.svc.cluster.local:${target.egressProxy.listenPort}`,
hostNetwork: target.egressProxy.hostNetwork,
sourceRef: target.egressProxy.sourceRef,
sourceType: target.egressProxy.sourceType,
applyToSub2Api: target.egressProxy.applyToSub2Api,
applyToSentinel: target.egressProxy.applyToSentinel,
valuesPrinted: false,
}
: null,
accountLocalProxy: target.accountLocalProxy?.enabled
? {
mode: `${target.id} Sub2API pod loopback proxy for manually configured account proxy URLs`,
listen: `${target.accountLocalProxy.listenHost}:${target.accountLocalProxy.listenPort}`,
sourceRef: target.accountLocalProxy.sourceRef,
sourceType: target.accountLocalProxy.sourceType,
valuesPrinted: false,
}
: null,
dataStores: isExternalTarget(target)
? [
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"],
appPoolCaps: {
databaseMaxOpenConns: 10,
databaseMaxIdleConns: 2,
redisPoolSize: 32,
redisMinIdleConns: 2,
},
standbyActivation: target.databaseMode === "external-pending"
? {
target: target.id,
currentMode: "predeployed-standby",
enableByYaml: `change target ${target.id} databaseMode to external-active and set appReplicas/redisReplicas to 1, then apply --target ${target.id} --confirm`,
sharedExternalDb: `${sub2api.runtime.database.host}:${sub2api.runtime.database.port}/${sub2api.runtime.database.dbName}`,
sentinelEnabled: targetHasSentinel(sub2api, target),
}
: null,
cleanup: managedResourceCleanupPlan(sub2api, target),
},
policy,
next: {
dryRun: `bun scripts/cli.ts platform-infra sub2api apply --target ${target.id} --dry-run`,
apply: target.databaseMode === "external-pending" && target.appReplicas === 0
? `bun scripts/cli.ts platform-infra sub2api apply --target ${target.id} --confirm # predeploy only; app replicas=0 until DB is ready`
: `bun scripts/cli.ts platform-infra sub2api apply --target ${target.id} --confirm`,
status: `bun scripts/cli.ts platform-infra sub2api status --target ${target.id}`,
validate: `bun scripts/cli.ts platform-infra sub2api validate --target ${target.id}`,
},
};
}
export async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Record<string, unknown>> {
const sub2api = readSub2ApiConfig();
const target = resolveTarget(sub2api, options.targetId);
const yaml = manifest(sub2api, target);
const policy = policyChecks(sub2api, yaml, target);
if (!policy.every((check) => check.ok)) {
return {
ok: false,
action: "platform-infra-sub2api-apply",
mode: "policy-blocked",
target: target.id,
policy,
};
}
if (options.confirm && !options.wait) {
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} ${isHostDockerTarget(target) ? "host-Docker" : "k3s"} platform-infra Sub2API desired state through the controlled UniDesk CLI`,
);
return {
ok: true,
action: "platform-infra-sub2api-apply",
mode: "async-job",
target: {
id: target.id,
route: target.route,
namespace: target.namespace,
},
job,
statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`,
next: {
status: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`,
rollout: `bun scripts/cli.ts platform-infra sub2api status --target ${target.id}`,
validate: `bun scripts/cli.ts platform-infra sub2api validate --target ${target.id}`,
},
};
}
if (options.dryRun) {
const accountLocalProxySecretMaterial = prepareAccountLocalProxySecret(sub2api, target);
const result = await capture(
config,
target.route,
["sh"],
isHostDockerTarget(target)
? hostDockerDryRunScript(sub2api, target)
: dryRunScript(yaml, target, accountLocalProxySecretMaterial),
);
const parsed = parseJsonOutput(result.stdout);
return {
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
action: "platform-infra-sub2api-apply",
mode: "dry-run",
target: {
id: target.id,
route: target.route,
namespace: target.namespace,
},
policy,
remote: parsed ?? compactCapture(result, { full: true }),
};
}
const secretMaterial = target.databaseMode === "external-active" ? prepareExternalActiveSecret(sub2api) : null;
const publicExposureSecretMaterial = preparePublicExposureSecret(sub2api, target);
const egressProxySecretMaterial = prepareEgressProxySecret(sub2api, target);
const accountLocalProxySecretMaterial = prepareAccountLocalProxySecret(sub2api, target);
const result = await capture(
config,
target.route,
["sh"],
isHostDockerTarget(target)
? hostDockerApplyScript(sub2api, target, secretMaterial)
: applyScript(sub2api, yaml, target, secretMaterial, publicExposureSecretMaterial, egressProxySecretMaterial, accountLocalProxySecretMaterial),
);
const parsed = parseJsonOutput(result.stdout);
const pk01Exposure = target.publicExposure === null ? null : await applyPk01PublicExposure(config, target);
return {
ok: result.exitCode === 0 && boolField(parsed, "ok", false) && (pk01Exposure === null || pk01Exposure.ok === true),
action: "platform-infra-sub2api-apply",
mode: "confirmed",
target: {
id: target.id,
route: target.route,
namespace: target.namespace,
},
policy,
remote: parsed ?? compactCapture(result, { full: true }),
pk01Exposure,
next: {
status: `bun scripts/cli.ts platform-infra sub2api status --target ${target.id}`,
validate: `bun scripts/cli.ts platform-infra sub2api validate --target ${target.id}`,
},
};
}
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"], isHostDockerTarget(target) ? hostDockerStatusScript(sub2api, target) : statusScript(sub2api, target));
const parsed = parseJsonOutput(result.stdout);
if (options.raw) {
return {
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
action: "platform-infra-sub2api-status",
target: {
id: target.id,
route: target.route,
namespace: target.namespace,
},
remote: compactCapture(result, { full: true }),
parsed,
};
}
return {
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
action: "platform-infra-sub2api-status",
target: {
id: target.id,
route: target.route,
namespace: target.namespace,
},
summary: parsed,
remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }),
};
}