refactor: split control-plane cli modules
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. utils module for scripts/src/platform-infra.ts.
|
||||
|
||||
// Moved mechanically from scripts/src/platform-infra.ts:4148-4600 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 { Sub2ApiConfig, Sub2ApiTargetConfig } from "./entry";
|
||||
import { serviceName } from "./entry";
|
||||
import { codexPoolSentinelResourceNames, imageRef, managedResourceCleanupPlan } from "./manifest";
|
||||
|
||||
export function validateExternalPendingScript(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
|
||||
const expectedImage = imageRef(sub2api, target);
|
||||
const database = sub2api.runtime.database;
|
||||
const redisService = sub2api.runtime.redis.serviceName;
|
||||
const cleanupPlan = managedResourceCleanupPlan(sub2api, target);
|
||||
return `
|
||||
set -u
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
capture_json() {
|
||||
name="$1"
|
||||
shift
|
||||
"$@" -o json >"$tmp/$name.json" 2>"$tmp/$name.err"
|
||||
rc=$?
|
||||
printf '%s' "$rc" >"$tmp/$name.rc"
|
||||
}
|
||||
capture_json ns kubectl get namespace ${target.namespace}
|
||||
capture_json app kubectl -n ${target.namespace} get deployment ${serviceName}
|
||||
capture_json redis kubectl -n ${target.namespace} get deployment ${redisService}
|
||||
capture_json deployments kubectl -n ${target.namespace} get deployments
|
||||
capture_json services kubectl -n ${target.namespace} get service
|
||||
capture_json cronjobs kubectl -n ${target.namespace} get cronjob
|
||||
capture_json statefulsets kubectl -n ${target.namespace} get statefulsets
|
||||
capture_json pvc kubectl -n ${target.namespace} get pvc
|
||||
capture_json configmap kubectl -n ${target.namespace} get configmap sub2api-config
|
||||
capture_json networkpolicy kubectl -n ${target.namespace} get networkpolicy allow-all
|
||||
python3 - "$tmp" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
tmp = sys.argv[1]
|
||||
|
||||
def rc(name):
|
||||
try:
|
||||
return int(open(os.path.join(tmp, f"{name}.rc"), encoding="utf-8").read() or "1")
|
||||
except FileNotFoundError:
|
||||
return 1
|
||||
|
||||
def load(name):
|
||||
path = os.path.join(tmp, f"{name}.json")
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
return json.load(open(path, encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
def items(name):
|
||||
data = load(name)
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
return data.get("items") or []
|
||||
|
||||
def text(name, limit=2000):
|
||||
path = os.path.join(tmp, name)
|
||||
try:
|
||||
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
|
||||
def is_allow_all_network_policy(item):
|
||||
if not isinstance(item, dict):
|
||||
return False
|
||||
spec = item.get("spec") or {}
|
||||
return (
|
||||
item.get("metadata", {}).get("name") == "allow-all"
|
||||
and spec.get("podSelector") == {}
|
||||
and set(spec.get("policyTypes") or []) == {"Ingress", "Egress"}
|
||||
and spec.get("ingress") == [{}]
|
||||
and spec.get("egress") == [{}]
|
||||
)
|
||||
|
||||
def deployment_summary(item):
|
||||
spec = (item or {}).get("spec") or {}
|
||||
status = (item or {}).get("status") or {}
|
||||
containers = ((spec.get("template") or {}).get("spec") or {}).get("containers") or []
|
||||
volumes = ((spec.get("template") or {}).get("spec") or {}).get("volumes") or []
|
||||
desired = spec.get("replicas", 1)
|
||||
available = status.get("availableReplicas", 0)
|
||||
return {
|
||||
"exists": isinstance(item, dict),
|
||||
"desired": desired,
|
||||
"availableReplicas": available,
|
||||
"readyReplicas": status.get("readyReplicas", 0),
|
||||
"ready": available >= desired,
|
||||
"images": [container.get("image") for container in containers],
|
||||
"volumes": volumes,
|
||||
}
|
||||
|
||||
services = items("services")
|
||||
deployments = items("deployments")
|
||||
cronjobs = items("cronjobs")
|
||||
statefulsets = items("statefulsets")
|
||||
pvcs = items("pvc")
|
||||
configmap = load("configmap")
|
||||
configmap_data = (configmap or {}).get("data") or {}
|
||||
cleanup_plan = json.loads(${JSON.stringify(JSON.stringify(cleanupPlan))})
|
||||
external_db_state = cleanup_plan["externalDbState"]
|
||||
redis_persistent_state = cleanup_plan["redisPersistentState"]
|
||||
public_exposure_state = cleanup_plan["publicExposure"]
|
||||
egress_proxy_state = cleanup_plan["egressProxy"]
|
||||
app = load("app")
|
||||
redis = load("redis")
|
||||
app_summary = deployment_summary(app)
|
||||
redis_summary = deployment_summary(redis)
|
||||
network_policy_obj = load("networkpolicy")
|
||||
network_policy_ok = rc("networkpolicy") == 0 and is_allow_all_network_policy(network_policy_obj)
|
||||
service_names = sorted(item.get("metadata", {}).get("name") for item in services)
|
||||
local_postgres_present = (
|
||||
any(item.get("metadata", {}).get("name") == external_db_state["postgresServiceName"] for item in services)
|
||||
or any(item.get("metadata", {}).get("name") == external_db_state["postgresStatefulSetName"] for item in statefulsets)
|
||||
or any(item.get("metadata", {}).get("name") in [external_db_state["postgresPvcName"], external_db_state["appDataPvcName"]] for item in pvcs)
|
||||
)
|
||||
redis_pvc_present = any(item.get("metadata", {}).get("name") == redis_persistent_state["pvcName"] for item in pvcs)
|
||||
public_exposure_deployment_present = any(item.get("metadata", {}).get("name") == public_exposure_state["deploymentName"] for item in deployments)
|
||||
egress_proxy_deployment_present = any(item.get("metadata", {}).get("name") == egress_proxy_state["deploymentName"] for item in deployments)
|
||||
egress_proxy_service_present = egress_proxy_state["serviceName"] in service_names
|
||||
sentinel_cronjob_present = any(item.get("metadata", {}).get("name") == "${codexPoolSentinelResourceNames().cronJobName}" for item in cronjobs)
|
||||
standby_disabled_ok = (
|
||||
not public_exposure_deployment_present
|
||||
and not egress_proxy_deployment_present
|
||||
and not egress_proxy_service_present
|
||||
and not sentinel_cronjob_present
|
||||
)
|
||||
redis_ephemeral = any(volume.get("emptyDir") == {} for volume in redis_summary["volumes"])
|
||||
configmap_aligned = (
|
||||
configmap_data.get("DATABASE_HOST") == "${database.host}"
|
||||
and configmap_data.get("DATABASE_PORT") == "${database.port}"
|
||||
and configmap_data.get("DATABASE_USER") == "${database.user}"
|
||||
and configmap_data.get("DATABASE_DBNAME") == "${database.dbName}"
|
||||
and configmap_data.get("DATABASE_SSLMODE") == "${database.sslMode}"
|
||||
and configmap_data.get("REDIS_HOST") == "${redisService}"
|
||||
)
|
||||
app_scaled_to_zero = app_summary["exists"] and app_summary["desired"] == ${target.appReplicas} and ${target.appReplicas} == 0
|
||||
image_aligned = "${expectedImage}" in app_summary["images"]
|
||||
redis_ready = redis_summary["exists"] and redis_summary["desired"] == ${target.redisReplicas} and redis_ephemeral and (redis_summary["ready"] or ${target.redisReplicas} == 0)
|
||||
payload = {
|
||||
"ok": rc("ns") == 0 and network_policy_ok and app_scaled_to_zero and image_aligned and redis_ready and configmap_aligned and not local_postgres_present and not redis_pvc_present and standby_disabled_ok and "${serviceName}" in service_names and "${redisService}" in service_names,
|
||||
"target": "${target.id}",
|
||||
"namespace": "${target.namespace}",
|
||||
"status": "pending-external-db",
|
||||
"databaseMode": "${target.databaseMode}",
|
||||
"redisMode": "${target.redisMode}",
|
||||
"expectedRedisReplicas": ${target.redisReplicas},
|
||||
"externalDatabase": {
|
||||
"sourceRef": "${database.sourceRef}",
|
||||
"host": "${database.host}",
|
||||
"port": ${database.port},
|
||||
"user": "${database.user}",
|
||||
"dbName": "${database.dbName}",
|
||||
"sslMode": "${database.sslMode}",
|
||||
"secretName": "${database.secretName}",
|
||||
"passwordKey": "${database.passwordKey}",
|
||||
"pendingAllowed": ${database.pendingAllowed ? "True" : "False"},
|
||||
"passwordPrinted": False,
|
||||
},
|
||||
"checks": {
|
||||
"namespaceExists": rc("ns") == 0,
|
||||
"allowAllNetworkPolicy": {"ok": network_policy_ok, "stderr": text("networkpolicy.err")},
|
||||
"appScaledToZero": {"ok": app_scaled_to_zero, "summary": app_summary},
|
||||
"imageAligned": {"ok": image_aligned, "desiredImage": "${expectedImage}", "runningImages": app_summary["images"]},
|
||||
"redisReadyEphemeral": {"ok": redis_ready, "summary": redis_summary, "redisPvcPresent": redis_pvc_present, "readinessRequired": ${target.redisReplicas} > 0},
|
||||
"serviceBoundary": {"serviceNames": service_names, "servicePresent": "${serviceName}" in service_names, "redisServicePresent": "${redisService}" in service_names},
|
||||
"externalDbOnly": {"ok": not local_postgres_present, "localPostgresPresent": local_postgres_present},
|
||||
"standbyDisabled": {
|
||||
"ok": standby_disabled_ok,
|
||||
"publicExposureDeploymentPresent": public_exposure_deployment_present,
|
||||
"egressProxyDeploymentPresent": egress_proxy_deployment_present,
|
||||
"egressProxyServicePresent": egress_proxy_service_present,
|
||||
"sentinelCronJobPresent": sentinel_cronjob_present,
|
||||
},
|
||||
"configMapAligned": {"ok": configmap_aligned, "databaseHost": configmap_data.get("DATABASE_HOST"), "redisHost": configmap_data.get("REDIS_HOST")},
|
||||
},
|
||||
"next": {
|
||||
"status": "bun scripts/cli.ts platform-infra sub2api status --target ${target.id}",
|
||||
"activateAfterDbReady": "update config/platform-infra/sub2api.yaml target ${target.id} appReplicas and redisReplicas to 1 after ${database.sourceRef}, ${database.secretName}/${database.passwordKey}, and runtime images are ready, then apply --target ${target.id} --confirm",
|
||||
},
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
sys.exit(0 if payload["ok"] else 1)
|
||||
PY
|
||||
`;
|
||||
}
|
||||
|
||||
export function boolField(value: Record<string, unknown> | null, key: string, defaultValue: boolean): boolean {
|
||||
if (value === null) return defaultValue;
|
||||
const field = value[key];
|
||||
return typeof field === "boolean" ? field : defaultValue;
|
||||
}
|
||||
|
||||
export function asRecordOrNull(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
}
|
||||
Reference in New Issue
Block a user