Files
pikasTech-unidesk/scripts/src/platform-infra/actions.ts
T
2026-06-26 10:32:43 +00:00

318 lines
14 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 { 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 { imageRef, isExternalTarget, managedResourceCleanupPlan, manifest, resolveTarget, targetDependencyImages, targetHasSentinel, targetImage } from "./manifest";
import { applyPk01PublicExposure } from "./pk01-public-exposure";
import { policyChecks } from "./policy";
import { 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,
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,
},
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,
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 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);
return {
ok: policy.every((check) => check.ok),
action: "platform-infra-sub2api-plan",
target: {
id: target.id,
route: target.route,
namespace: target.namespace,
role: target.role,
manifestPath,
configPath,
fieldManager,
serviceDns: `${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: {
databaseMode: target.databaseMode,
redisMode: target.redisMode,
appReplicas: target.appReplicas,
redisReplicas: target.redisReplicas,
publicExposure: target.publicExposure === null ? null : publicExposureSummary(target.publicExposure),
egressProxy: target.egressProxy === null ? null : egressProxySummary(target.egressProxy),
},
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: 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,
} : 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.`
: 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.`
: "ClusterIP only; no public ingress or node-level exposure.",
resourcePolicy: "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.",
publicExposure: target.publicExposure?.enabled
? {
mode: "pk01-caddy-frp-direct",
dataPath: `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}`,
sourceRef: target.egressProxy.sourceRef,
sourceType: target.egressProxy.sourceType,
applyToSub2Api: target.egressProxy.applyToSub2Api,
applyToSentinel: target.egressProxy.applyToSentinel,
valuesPrinted: false,
}
: null,
dataStores: isExternalTarget(target)
? [
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} k3s platform-infra Sub2API manifests 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 result = await capture(config, target.route, ["sh"], dryRunScript(yaml, target));
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 result = await capture(config, target.route, ["sh"], applyScript(sub2api, yaml, target, secretMaterial, publicExposureSecretMaterial, egressProxySecretMaterial));
const parsed = parseJsonOutput(result.stdout);
const pk01Exposure = publicExposureSecretMaterial === 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"], 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 }),
};
}