Files
pikasTech-unidesk/scripts/src/platform-infra/apply-script.ts
T
2026-06-26 03:53:09 +00:00

462 lines
20 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. apply-script module for scripts/src/platform-infra.ts.
// Moved mechanically from scripts/src/platform-infra.ts:2703-3090 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 { EgressProxySecretMaterial, ExternalActiveSecretMaterial, PublicExposureSecretMaterial, Sub2ApiConfig, Sub2ApiPublicExposureConfig, Sub2ApiTargetConfig } from "./entry";
import { fieldManager, requiredSecretKeys, sub2apiCaddyManagedMarker } from "./entry";
import { managedResourceCleanupPlan } from "./manifest";
export function renderPk01Caddyfile(target: Sub2ApiTargetConfig, exposure: Sub2ApiPublicExposureConfig): string {
const apexHost = baseDomain(exposure.dns.hostname);
const apiBlock = renderCaddyManagedBlock(
sub2apiCaddyManagedMarker(target),
renderSimpleReverseProxyCaddySiteBlock({
hostname: exposure.dns.hostname,
upstream: `127.0.0.1:${exposure.frpc.remotePort}`,
responseHeaderTimeoutSeconds: exposure.pk01.responseHeaderTimeoutSeconds,
}),
);
return `{
email ${exposure.pk01.caddyEmail}
storage file_system {
root ${exposure.pk01.caddyStorageDir}
}
}
${apexHost}, www.${apexHost} {
reverse_proxy 127.0.0.1:${exposure.pk01.pikanodeHttpHostPort}
}
${apiBlock.trim()}
`;
}
export function renderPk01CaddyService(exposure: Sub2ApiPublicExposureConfig): string {
return `[Unit]
Description=UniDesk PK01 Caddy edge for PikaPython and Sub2API
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
ExecStart=${exposure.pk01.caddyBinaryPath} run --environ --config ${exposure.pk01.caddyConfigPath}
ExecReload=${exposure.pk01.caddyBinaryPath} reload --config ${exposure.pk01.caddyConfigPath} --force
Restart=always
RestartSec=3
LimitNOFILE=1048576
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
Environment=XDG_DATA_HOME=${exposure.pk01.caddyStorageDir}
Environment=XDG_CONFIG_HOME=/etc/caddy
[Install]
WantedBy=multi-user.target
`;
}
export function baseDomain(hostname: string): string {
const parts = hostname.split(".");
return parts.length <= 2 ? hostname : parts.slice(-2).join(".");
}
export function secretRoot(sub2api: Sub2ApiConfig): string {
const root = sub2api.runtime.secrets.root;
return isAbsolute(root) ? root : rootPath(root);
}
export function writeEnvFile(path: string, values: Record<string, string>): void {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
const lines = Object.keys(values)
.sort()
.map((key) => `${key}=${quoteEnv(values[key])}`);
writeFileSync(path, `${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
chmodSync(path, 0o600);
}
export function quoteEnv(value: string): string {
if (/^[A-Za-z0-9_./:@%?&=+-]+$/u.test(value)) return value;
return `'${value.replaceAll("'", "'\"'\"'")}'`;
}
export function dryRunScript(yaml: string, target: Sub2ApiTargetConfig): string {
const encoded = Buffer.from(yaml, "utf8").toString("base64");
return `
set -u
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
manifest="$tmp/sub2api.k8s.yaml"
printf '%s' '${encoded}' | base64 -d > "$manifest"
client_out="$tmp/client.out"
client_err="$tmp/client.err"
server_out="$tmp/server.out"
server_err="$tmp/server.err"
kubectl apply --dry-run=client -f "$manifest" >"$client_out" 2>"$client_err"
client_rc=$?
if kubectl get namespace ${target.namespace} >/dev/null 2>&1; then
namespace_exists=true
kubectl apply --server-side --dry-run=server --field-manager=${fieldManager} -f "$manifest" >"$server_out" 2>"$server_err"
server_rc=$?
else
namespace_exists=false
server_rc=0
printf '%s\\n' 'server dry-run skipped because namespace does not exist yet; first apply creates it before namespaced resources' >"$server_out"
: >"$server_err"
fi
python3 - "$client_rc" "$server_rc" "$namespace_exists" "$client_out" "$client_err" "$server_out" "$server_err" <<'PY'
import json
import sys
client_rc = int(sys.argv[1])
server_rc = int(sys.argv[2])
namespace_exists = sys.argv[3] == "true"
paths = sys.argv[4:]
def text(path):
try:
return open(path, encoding="utf-8").read()
except FileNotFoundError:
return ""
payload = {
"ok": client_rc == 0 and server_rc == 0,
"target": "${target.id}",
"namespace": "${target.namespace}",
"namespaceExistsBeforeDryRun": namespace_exists,
"clientDryRun": {
"exitCode": client_rc,
"stdout": text(paths[0])[-4000:],
"stderr": text(paths[1])[-4000:],
},
"serverDryRun": {
"exitCode": server_rc,
"disposition": "executed" if namespace_exists else "skipped-namespace-missing",
"stdout": text(paths[2])[-4000:],
"stderr": text(paths[3])[-4000:],
},
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
`;
}
export function applyScript(
sub2api: Sub2ApiConfig,
yaml: string,
target: Sub2ApiTargetConfig,
secretMaterial: ExternalActiveSecretMaterial | null,
publicExposureSecretMaterial: PublicExposureSecretMaterial | null,
egressProxySecretMaterial: EgressProxySecretMaterial | null,
): string {
const encoded = Buffer.from(yaml, "utf8").toString("base64");
const cleanupPlan = managedResourceCleanupPlan(sub2api, target);
const appSecretName = sub2api.runtime.database.secretName;
const publicExposureSecretName = publicExposureSecretMaterial?.secretName ?? sub2api.defaults.cleanup.publicExposure.secretName;
const publicExposureSecretKey = publicExposureSecretMaterial?.secretKey ?? "frpc.toml";
const egressProxySecretName = egressProxySecretMaterial?.secretName ?? sub2api.defaults.cleanup.egressProxy.secretName;
const egressProxySecretKey = egressProxySecretMaterial?.secretKey ?? "config.json";
if (target.databaseMode === "external-active" && secretMaterial === null) throw new Error("external-active apply requires secret material");
const externalSecretFiles = secretMaterial === null
? ""
: requiredSecretKeys.map((key) => {
const value = Buffer.from(secretMaterial.values[key], "utf8").toString("base64");
return ` printf '%s' '${value}' | base64 -d >"$tmp/secret.${key}"`;
}).join("\n");
const secretSourceSummary = secretMaterial === null
? "None"
: `json.loads(${JSON.stringify(JSON.stringify({
sourceRef: secretMaterial.sourceRef,
sourcePath: secretMaterial.sourcePath,
appSourceRef: secretMaterial.appSourceRef,
appSourcePath: secretMaterial.appSourcePath,
sourceAction: secretMaterial.action,
fingerprint: secretMaterial.fingerprint,
valuesPrinted: false,
}))})`;
const exposureSecretFile = publicExposureSecretMaterial === null
? ""
: ` printf '%s' '${Buffer.from(publicExposureSecretMaterial.frpcToml, "utf8").toString("base64")}' | base64 -d >"$tmp/frpc.toml"`;
const exposureSecretSummary = publicExposureSecretMaterial === null
? "None"
: `json.loads(${JSON.stringify(JSON.stringify({
sourceRef: publicExposureSecretMaterial.sourceRef,
sourcePath: publicExposureSecretMaterial.sourcePath,
secretName: publicExposureSecretMaterial.secretName,
secretKey: publicExposureSecretMaterial.secretKey,
fingerprint: publicExposureSecretMaterial.fingerprint,
valuesPrinted: false,
}))})`;
const egressProxySecretFile = egressProxySecretMaterial === null
? ""
: ` printf '%s' '${Buffer.from(egressProxySecretMaterial.configJson, "utf8").toString("base64")}' | base64 -d >"$tmp/egress-proxy-config.json"`;
const egressProxySecretSummary = egressProxySecretMaterial === null
? "None"
: `json.loads(${JSON.stringify(JSON.stringify({
sourceRef: egressProxySecretMaterial.sourceRef,
sourcePath: egressProxySecretMaterial.sourcePath,
secretName: egressProxySecretMaterial.secretName,
secretKey: egressProxySecretMaterial.secretKey,
fingerprint: egressProxySecretMaterial.fingerprint,
sourceBytes: egressProxySecretMaterial.sourceBytes,
selectedOutbound: egressProxySecretMaterial.selectedOutbound,
sourceDiagnostics: egressProxySecretMaterial.sourceDiagnostics,
proxyUrl: egressProxySecretMaterial.proxyUrl,
noProxy: egressProxySecretMaterial.noProxy,
valuesPrinted: false,
}))})`;
return `
set -u
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
manifest="$tmp/sub2api.k8s.yaml"
printf '%s' '${encoded}' | base64 -d > "$manifest"
ns_out="$tmp/ns.out"
ns_err="$tmp/ns.err"
secret_out="$tmp/secret.out"
secret_err="$tmp/secret.err"
exposure_secret_out="$tmp/exposure-secret.out"
exposure_secret_err="$tmp/exposure-secret.err"
egress_secret_out="$tmp/egress-secret.out"
egress_secret_err="$tmp/egress-secret.err"
apply_out="$tmp/apply.out"
apply_err="$tmp/apply.err"
cleanup_out="$tmp/cleanup.out"
cleanup_err="$tmp/cleanup.err"
kubectl create namespace ${target.namespace} --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$ns_out" 2>"$ns_err"
ns_rc=$?
secret_action="unknown"
secret_rc=0
exposure_secret_action="not-enabled"
exposure_secret_rc=0
egress_secret_action="not-enabled"
egress_secret_rc=0
if [ "$ns_rc" -eq 0 ]; then
if [ "${target.databaseMode}" = "external-pending" ]; then
secret_action="external-pending-not-managed"
: >"$secret_out"
printf '%s\\n' 'external DB target expects its DB credential Secret from the platform DB handoff; predeploy does not create placeholder secrets' >"$secret_err"
elif [ "${target.databaseMode}" = "external-active" ]; then
${externalSecretFiles}
kubectl -n ${target.namespace} create secret generic ${appSecretName} \\
--from-file=POSTGRES_PASSWORD="$tmp/secret.POSTGRES_PASSWORD" \\
--from-file=ADMIN_PASSWORD="$tmp/secret.ADMIN_PASSWORD" \\
--from-file=JWT_SECRET="$tmp/secret.JWT_SECRET" \\
--from-file=TOTP_ENCRYPTION_KEY="$tmp/secret.TOTP_ENCRYPTION_KEY" \\
--dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$secret_out" 2>"$secret_err"
secret_rc=$?
secret_action="external-active-synced"
elif kubectl -n ${target.namespace} get secret ${appSecretName} >/dev/null 2>&1; then
secret_action="kept-existing"
: >"$secret_out"
: >"$secret_err"
else
rand_hex() {
bytes="$1"
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex "$bytes"
else
dd if=/dev/urandom bs="$bytes" count=1 2>/dev/null | od -An -tx1 | tr -d ' \\n'
fi
}
kubectl -n ${target.namespace} create secret generic ${appSecretName} \\
--from-literal=POSTGRES_PASSWORD="$(rand_hex 32)" \\
--from-literal=ADMIN_PASSWORD="$(rand_hex 16)" \\
--from-literal=JWT_SECRET="$(rand_hex 32)" \\
--from-literal=TOTP_ENCRYPTION_KEY="$(rand_hex 32)" \\
--dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$secret_out" 2>"$secret_err"
secret_rc=$?
secret_action="created"
fi
if [ "${publicExposureSecretMaterial === null ? "false" : "true"}" = "true" ]; then
${exposureSecretFile}
kubectl -n ${target.namespace} create secret generic ${publicExposureSecretName} \\
--from-file=${publicExposureSecretKey}="$tmp/frpc.toml" \\
--dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$exposure_secret_out" 2>"$exposure_secret_err"
exposure_secret_rc=$?
exposure_secret_action="synced"
else
: >"$exposure_secret_out"
: >"$exposure_secret_err"
fi
if [ "${egressProxySecretMaterial === null ? "false" : "true"}" = "true" ]; then
${egressProxySecretFile}
kubectl -n ${target.namespace} create secret generic ${egressProxySecretName} \\
--from-file=${egressProxySecretKey}="$tmp/egress-proxy-config.json" \\
--dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$egress_secret_out" 2>"$egress_secret_err"
egress_secret_rc=$?
egress_secret_action="synced"
else
: >"$egress_secret_out"
: >"$egress_secret_err"
fi
fi
apply_rc=1
if [ "$ns_rc" -eq 0 ] && [ "$secret_rc" -eq 0 ] && [ "$exposure_secret_rc" -eq 0 ] && [ "$egress_secret_rc" -eq 0 ]; then
kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f "$manifest" >"$apply_out" 2>"$apply_err"
apply_rc=$?
else
: >"$apply_out"
printf '%s\\n' 'skipped because namespace, app secret, public exposure secret, or egress proxy secret step failed' >"$apply_err"
fi
cleanup_rc=0
if [ "$apply_rc" -eq 0 ]; then
python3 - "$cleanup_out" "$cleanup_err" <<'PY'
import json
import subprocess
import sys
import time
out_path, err_path = sys.argv[1:3]
namespace = ${JSON.stringify(target.namespace)}
plan = json.loads(${JSON.stringify(JSON.stringify(cleanupPlan))})
def run(args):
proc = subprocess.run(["kubectl", "-n", namespace, *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return {
"args": ["kubectl", "-n", namespace, *args],
"exitCode": proc.returncode,
"stdout": proc.stdout.decode("utf-8", errors="replace")[-2000:],
"stderr": proc.stderr.decode("utf-8", errors="replace")[-2000:],
}
def delete(kind, name):
return run(["delete", kind, name, "--ignore-not-found=true", "--wait=false"])
items = []
external_db_state = plan["externalDbState"]
redis_persistent_state = plan["redisPersistentState"]
if external_db_state["enabled"]:
items.append({"name": "legacy-postgres-statefulset", **delete("statefulset", external_db_state["postgresStatefulSetName"])})
items.append({"name": "legacy-postgres-service", **delete("service", external_db_state["postgresServiceName"])})
items.append({"name": "legacy-postgres-pvc", **delete("pvc", external_db_state["postgresPvcName"])})
items.append({"name": "legacy-app-data-pvc", **delete("pvc", external_db_state["appDataPvcName"])})
if redis_persistent_state["enabled"]:
items.append({"name": "legacy-redis-pvc", **delete("pvc", redis_persistent_state["pvcName"])})
if not plan["publicExposure"]["enabled"]:
items.append({"name": "disabled-public-frpc-deployment", **delete("deployment", plan["publicExposure"]["deploymentName"])})
items.append({"name": "disabled-public-frpc-configmap", **delete("configmap", plan["publicExposure"]["configMapName"])})
items.append({"name": "disabled-public-frpc-secret", **delete("secret", plan["publicExposure"]["secretName"])})
if not plan["egressProxy"]["enabled"]:
items.append({"name": "disabled-egress-proxy-deployment", **delete("deployment", plan["egressProxy"]["deploymentName"])})
items.append({"name": "disabled-egress-proxy-service", **delete("service", plan["egressProxy"]["serviceName"])})
items.append({"name": "disabled-egress-proxy-secret", **delete("secret", plan["egressProxy"]["secretName"])})
if not plan["sentinel"]["enabled"]:
items.append({"name": "disabled-sentinel-cronjob", **delete("cronjob", plan["sentinel"]["cronJobName"])})
items.append({"name": "disabled-sentinel-jobs", **run(["delete", "job", "-l", f"app.kubernetes.io/name={plan['sentinel']['cronJobName']}", "--ignore-not-found=true", "--wait=false"])})
items.append({"name": "disabled-sentinel-configmap", **delete("configmap", plan["sentinel"]["configMapName"])})
items.append({"name": "disabled-sentinel-credentials-secret", **delete("secret", plan["sentinel"]["credentialsSecretName"])})
items.append({"name": "disabled-sentinel-serviceaccount", **delete("serviceaccount", plan["sentinel"]["serviceAccountName"])})
items.append({"name": "disabled-sentinel-role", **delete("role", plan["sentinel"]["roleName"])})
items.append({"name": "disabled-sentinel-rolebinding", **delete("rolebinding", plan["sentinel"]["roleBindingName"])})
watch = []
if external_db_state["enabled"]:
watch.extend([
("statefulset", external_db_state["postgresStatefulSetName"]),
("service", external_db_state["postgresServiceName"]),
("pvc", external_db_state["postgresPvcName"]),
("pvc", external_db_state["appDataPvcName"]),
])
if redis_persistent_state["enabled"]:
watch.append(("pvc", redis_persistent_state["pvcName"]))
if not plan["publicExposure"]["enabled"]:
watch.append(("deployment", plan["publicExposure"]["deploymentName"]))
if not plan["egressProxy"]["enabled"]:
watch.append(("deployment", plan["egressProxy"]["deploymentName"]))
watch.append(("service", plan["egressProxy"]["serviceName"]))
if not plan["sentinel"]["enabled"]:
watch.append(("cronjob", plan["sentinel"]["cronJobName"]))
deadline = time.time() + 90
remaining = []
while True:
remaining = []
for kind, name in watch:
result = run(["get", kind, name])
if result["exitCode"] == 0:
remaining.append({"kind": kind, "name": name})
if not remaining or time.time() >= deadline:
break
time.sleep(3)
payload = {
"ok": all(item["exitCode"] == 0 for item in items) and not remaining,
"plan": plan,
"items": items,
"remainingAfterWait": remaining,
"valuesPrinted": False,
}
open(out_path, "w", encoding="utf-8").write(json.dumps(payload, ensure_ascii=False, indent=2))
open(err_path, "w", encoding="utf-8").write("")
sys.exit(0 if payload["ok"] else 1)
PY
cleanup_rc=$?
else
: >"$cleanup_out"
printf '%s\\n' 'skipped because apply failed' >"$cleanup_err"
cleanup_rc=1
fi
python3 - "$ns_rc" "$secret_rc" "$exposure_secret_rc" "$egress_secret_rc" "$apply_rc" "$cleanup_rc" "$secret_action" "$exposure_secret_action" "$egress_secret_action" "$ns_out" "$ns_err" "$secret_out" "$secret_err" "$exposure_secret_out" "$exposure_secret_err" "$egress_secret_out" "$egress_secret_err" "$apply_out" "$apply_err" "$cleanup_out" "$cleanup_err" <<'PY'
import json
import sys
ns_rc = int(sys.argv[1])
secret_rc = int(sys.argv[2])
exposure_secret_rc = int(sys.argv[3])
egress_secret_rc = int(sys.argv[4])
apply_rc = int(sys.argv[5])
cleanup_rc = int(sys.argv[6])
secret_action = sys.argv[7]
exposure_secret_action = sys.argv[8]
egress_secret_action = sys.argv[9]
paths = sys.argv[10:]
def text(path):
try:
return open(path, encoding="utf-8").read()
except FileNotFoundError:
return ""
def parsed(path):
try:
return json.load(open(path, encoding="utf-8"))
except Exception:
return None
payload = {
"ok": ns_rc == 0 and secret_rc == 0 and exposure_secret_rc == 0 and egress_secret_rc == 0 and apply_rc == 0 and cleanup_rc == 0,
"target": "${target.id}",
"namespace": "${target.namespace}",
"databaseMode": "${target.databaseMode}",
"appReplicas": ${target.appReplicas},
"redisReplicas": ${target.redisReplicas},
"secret": {
"name": "${appSecretName}",
"action": secret_action,
"requiredKeys": ${JSON.stringify(requiredSecretKeys)},
"managedByThisApply": ${target.databaseMode === "external-pending" ? "False" : "True"},
"externalActiveSource": ${secretSourceSummary},
"valuesPrinted": False,
},
"publicExposure": ${exposureSecretSummary},
"egressProxy": ${egressProxySecretSummary},
"steps": {
"namespace": {"exitCode": ns_rc, "stdout": text(paths[0])[-4000:], "stderr": text(paths[1])[-4000:]},
"secret": {"exitCode": secret_rc, "stdout": text(paths[2])[-4000:], "stderr": text(paths[3])[-4000:]},
"publicExposureSecret": {"exitCode": exposure_secret_rc, "action": exposure_secret_action, "stdout": text(paths[4])[-4000:], "stderr": text(paths[5])[-4000:]},
"egressProxySecret": {"exitCode": egress_secret_rc, "action": egress_secret_action, "stdout": text(paths[6])[-4000:], "stderr": text(paths[7])[-4000:]},
"apply": {"exitCode": apply_rc, "stdout": text(paths[8])[-8000:], "stderr": text(paths[9])[-4000:]},
"cleanup": {"exitCode": cleanup_rc, "summary": parsed(paths[10]), "stdout": text(paths[10])[-8000:], "stderr": text(paths[11])[-4000:]},
},
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
`;
}