refactor: split control-plane cli modules
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
// 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,
|
||||
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; api.pikapython.com 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 }),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user