// 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): 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 `; }