diff --git a/config/platform-infra/sub2rank.yaml b/config/platform-infra/sub2rank.yaml new file mode 100644 index 00000000..4f6e27f5 --- /dev/null +++ b/config/platform-infra/sub2rank.yaml @@ -0,0 +1,100 @@ +version: 1 +kind: platform-infra-sub2rank + +metadata: + id: sub2rank-public + owner: unidesk + relatedIssues: [] + +defaults: + targetId: NC01 + +application: + sourceRoot: /root/sub2rank + configRef: /root/sub2rank/config/sub2rank.yaml + runtimeTarget: k8s + +image: + repository: 127.0.0.1:5000/sub2rank/sub2rank + tag: 14f51f5e1242d0e048f51bbea5937e3022361e9a + pullPolicy: IfNotPresent + +targets: + - id: NC01 + route: NC01:k3s + namespace: platform-infra + enabled: true + replicas: 1 + publicExposure: + enabled: true + publicBaseUrl: https://rank.pikapython.com + dns: + hostname: rank.pikapython.com + expectedA: 82.156.23.220 + resolvers: + - 1.1.1.1 + - 8.8.8.8 + - 223.5.5.5 + - 114.114.114.114 + frpc: + deploymentName: sub2rank-frpc + configMapName: sub2rank-frpc-config + configKey: frpc.toml + image: 127.0.0.1:5000/hwlab/frpc:v0.68.1 + serverAddr: 82.156.23.220 + serverPort: 22000 + proxyName: platform-infra-sub2rank-nc01-web + remotePort: 22095 + localIP: sub2rank.platform-infra.svc.cluster.local + localPort: 8080 + tokenEnvName: FRP_TOKEN + tokenTargetKey: FRP_TOKEN + pk01: + route: PK01 + caddyConfigPath: /etc/caddy/Caddyfile + caddyServiceName: caddy + responseHeaderTimeoutSeconds: 60 + +runtime: + sharedNetworkPolicyRef: + name: allow-all + managedBySub2Rank: false + service: + name: sub2rank + port: 8080 + configMap: + name: sub2rank-config + key: sub2rank.yaml + mountPath: /etc/sub2rank/sub2rank.yaml + command: + - bun + - src/server.ts + - --config + - /etc/sub2rank/sub2rank.yaml + - --runtime + - k8s + storage: + claimName: sub2rank-data + size: 1Gi + mountPath: /var/lib/sub2rank + secret: + configRef: config/secrets-distribution.yaml + declarationId: sub2rank-runtime + requiredTargetKeys: + - SUB2RANK_ADMIN_TOKEN + - SUB2API_ADMIN_EMAIL + - SUB2API_ADMIN_PASSWORD + - FRP_TOKEN + appEnvTargetKeys: + - SUB2RANK_ADMIN_TOKEN + - SUB2API_ADMIN_EMAIL + - SUB2API_ADMIN_PASSWORD + probes: + healthPath: /health + initialDelaySeconds: 3 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 6 + rollout: + fieldManager: unidesk-platform-sub2rank + waitTimeoutSeconds: 180 diff --git a/scripts/src/platform-infra-public-service.ts b/scripts/src/platform-infra-public-service.ts index b1bec3f9..3137f1e0 100644 --- a/scripts/src/platform-infra-public-service.ts +++ b/scripts/src/platform-infra-public-service.ts @@ -1,4 +1,6 @@ import { Buffer } from "node:buffer"; +import { createHash } from "node:crypto"; +import { Resolver } from "node:dns/promises"; import type { UniDeskConfig } from "./config"; import { applyPk01CaddyManagedBlock, caddyManagedBlockMarkers } from "./pk01-caddy"; import { capture, compactCapture, fingerprintValues, parseJsonOutput, redactText, shQuote } from "./platform-infra-ops-library"; @@ -48,6 +50,24 @@ export interface FrpcSecretMaterial { valuesPrinted: false; } +export interface FrpcTokenSecretReference { + configMapName: string; + configKey: string; + tokenSecretName: string; + tokenSecretKey: string; + tokenEnvName: string; +} + +export interface PublicServiceRuntimeResources { + appDeploymentName: string; + serviceName: string; + persistentVolumeClaimName: string; + configMapName: string; + secretName: string; + requiredSecretKeys: string[]; + sharedNetworkPolicyName?: string; +} + export async function applyPk01CaddyBlock( config: UniDeskConfig, serviceId: string, @@ -69,20 +89,7 @@ export function prepareFrpcSecret(params: { const sourcePath = `${params.secretRoot.replace(/\/+$/u, "")}/${exposure.frpc.tokenSourceRef}`; const values = params.parseEnvFile(params.readTextFile(sourcePath)); const token = params.requiredEnvValue(values, exposure.frpc.tokenSourceKey, exposure.frpc.tokenSourceRef); - const frpcToml = [ - `serverAddr = "${exposure.frpc.serverAddr}"`, - `serverPort = ${exposure.frpc.serverPort}`, - "loginFailExit = true", - `auth.token = "${escapeTomlString(token)}"`, - "", - "[[proxies]]", - `name = "${exposure.frpc.proxyName}"`, - 'type = "tcp"', - `localIP = "${exposure.frpc.localIP}"`, - `localPort = ${exposure.frpc.localPort}`, - `remotePort = ${exposure.frpc.remotePort}`, - "", - ].join("\n"); + const frpcToml = renderFrpcToml(exposure, escapeTomlString(token)); return { sourceRef: exposure.frpc.tokenSourceRef, sourcePath: params.sourcePathRedactor(sourcePath), @@ -94,6 +101,83 @@ export function prepareFrpcSecret(params: { }; } +export function renderEnvSecretFrpcManifest(target: PublicServiceTarget, secret: FrpcTokenSecretReference): string { + const exposure = target.publicExposure; + if (!exposure.enabled) return ""; + const frpcToml = renderFrpcToml(exposure, `{{ .Envs.${secret.tokenEnvName} }}`); + const configFingerprint = createHash("sha256").update(frpcToml).digest("hex"); + return `--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: ${secret.configMapName} + namespace: ${target.namespace} + labels: + app.kubernetes.io/name: ${exposure.frpc.deploymentName} + app.kubernetes.io/component: tunnel + app.kubernetes.io/part-of: platform-infra + app.kubernetes.io/managed-by: unidesk +data: + ${secret.configKey}: | +${indentYamlBlock(frpcToml, 4)} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${exposure.frpc.deploymentName} + namespace: ${target.namespace} + labels: + app.kubernetes.io/name: ${exposure.frpc.deploymentName} + app.kubernetes.io/component: tunnel + app.kubernetes.io/part-of: platform-infra + app.kubernetes.io/managed-by: unidesk + unidesk.ai/runtime-node: ${target.id} + unidesk.ai/public-hostname: ${exposure.dns.hostname} +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: ${exposure.frpc.deploymentName} + app.kubernetes.io/component: tunnel + template: + metadata: + labels: + app.kubernetes.io/name: ${exposure.frpc.deploymentName} + app.kubernetes.io/component: tunnel + app.kubernetes.io/part-of: platform-infra + annotations: + unidesk.ai/public-base-url: "${exposure.publicBaseUrl}" + unidesk.ai/frp-server: "${exposure.frpc.serverAddr}:${exposure.frpc.serverPort}" + unidesk.ai/frp-remote-port: "${exposure.frpc.remotePort}" + unidesk.ai/frpc-config-sha256: "${configFingerprint}" + spec: + containers: + - name: frpc + image: ${exposure.frpc.image} + imagePullPolicy: IfNotPresent + args: + - -c + - /etc/frp/frpc.toml + env: + - name: ${secret.tokenEnvName} + valueFrom: + secretKeyRef: + name: ${secret.tokenSecretName} + key: ${secret.tokenSecretKey} + volumeMounts: + - name: frpc-config + mountPath: /etc/frp/frpc.toml + subPath: ${secret.configKey} + readOnly: true + volumes: + - name: frpc-config + configMap: + name: ${secret.configMapName} +`; +} + export function renderFrpcManifest(target: PublicServiceTarget): string { const exposure = target.publicExposure; if (!exposure.enabled) return ""; @@ -146,15 +230,21 @@ spec: `; } -export function publicServicePolicyChecks(yaml: string, target: PublicServiceTarget, serviceName: string): Array> { - return [ +export function publicServicePolicyChecks( + yaml: string, + target: PublicServiceTarget, + serviceName: string, + options: { requireAllowAllManifest?: boolean } = {}, +): Array> { + const checks: Array> = [ { name: "no-ingress", ok: !/^\s*kind:\s*Ingress\s*$/mu.test(yaml), detail: `${serviceName} public exposure must use PK01 Caddy+FRP, not Kubernetes Ingress.` }, { name: "no-nodeport-or-loadbalancer", ok: !/^\s*type:\s*(NodePort|LoadBalancer)\s*$/mu.test(yaml), detail: "Services must stay ClusterIP." }, { name: "no-host-network", ok: !/^\s*hostNetwork:\s*true\s*$/mu.test(yaml), detail: "Pods must not use host network." }, { name: "no-host-port", ok: !/^\s*hostPort:\s*[0-9]+\s*$/mu.test(yaml), detail: "Pods must not expose host ports." }, { name: "no-cpu-memory-resources", ok: !/^\s*(cpu|memory):\s*/mu.test(yaml), detail: "No CPU/memory request or limit objects are rendered." }, - { name: "allow-all-network-policy", ok: hasAllowAllNetworkPolicy(yaml, target.namespace), detail: `NetworkPolicy/allow-all exists in ${target.namespace}.` }, ]; + if (options.requireAllowAllManifest ?? true) checks.push({ name: "allow-all-network-policy", ok: hasAllowAllNetworkPolicy(yaml, target.namespace), detail: `NetworkPolicy/allow-all exists in ${target.namespace}.` }); + return checks; } export function dryRunManifestScript(params: { yaml: string; target: PublicServiceTarget; fieldManager: string; manifestName: string }): string { @@ -225,6 +315,216 @@ export function publicHttpProbe(baseUrl: string, path: string, options: { header }; } +export async function publicDnsProbe(dns: PublicServiceExposure["dns"]): Promise> { + const observations = await Promise.all(dns.resolvers.map(async (resolverAddress) => { + const resolver = new Resolver(); + resolver.setServers([resolverAddress]); + try { + const addresses = [...new Set(await resolver.resolve4(dns.hostname))].sort(); + return { resolver: resolverAddress, ok: addresses.includes(dns.expectedA), addresses, error: null }; + } catch (error) { + return { resolver: resolverAddress, ok: false, addresses: [], error: redactText(error instanceof Error ? error.message : String(error)).slice(0, 500) }; + } + })); + return { + ok: observations.length > 0 && observations.every((item) => item.ok), + hostname: dns.hostname, + expectedA: dns.expectedA, + observations, + }; +} + +export function applyManifestWithExistingSecretScript(params: { + yaml: string; + target: PublicServiceTarget; + fieldManager: string; + manifestName: string; + secretName: string; + requiredSecretKeys: string[]; + deploymentNames: string[]; + waitTimeoutSeconds: number; +}): string { + const encoded = Buffer.from(params.yaml, "utf8").toString("base64"); + const requiredSecretKeys = JSON.stringify(params.requiredSecretKeys); + const deploymentNames = JSON.stringify(params.deploymentNames); + return ` +set -u +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +manifest="$tmp/${params.manifestName}.k8s.yaml" +printf '%s' '${encoded}' | base64 -d > "$manifest" +kubectl -n ${params.target.namespace} get secret ${params.secretName} -o json >"$tmp/secret.json" 2>"$tmp/secret.err" +secret_get_rc=$? +python3 - "$tmp/secret.json" "$tmp/secret-check.json" '${requiredSecretKeys}' "$secret_get_rc" <<'PY' +import json, sys +source_path, result_path, required_json, get_rc_text = sys.argv[1:] +required = json.loads(required_json) +get_rc = int(get_rc_text) +data = {} +if get_rc == 0: + try: + data = json.load(open(source_path, encoding="utf-8")).get("data", {}) + except Exception: + data = {} +present = sorted(key for key in required if isinstance(data.get(key), str) and len(data[key]) > 0) +missing = sorted(set(required) - set(present)) +payload = {"ok": get_rc == 0 and not missing, "name": "${params.secretName}", "requiredKeys": sorted(required), "presentKeys": present, "missingKeys": missing, "valuesPrinted": False} +open(result_path, "w", encoding="utf-8").write(json.dumps(payload, ensure_ascii=False)) +sys.exit(0 if payload["ok"] else 1) +PY +secret_check_rc=$? +if [ "$secret_check_rc" -eq 0 ]; then + kubectl apply --server-side --force-conflicts --field-manager=${params.fieldManager} -f "$manifest" >"$tmp/apply.out" 2>"$tmp/apply.err" + apply_rc=$? +else + : >"$tmp/apply.out" + printf '%s\n' 'manifest apply skipped because the declared runtime Secret is missing or incomplete' >"$tmp/apply.err" + apply_rc=1 +fi +printf '%s' '${deploymentNames}' >"$tmp/deployments.json" +if [ "$apply_rc" -eq 0 ]; then + python3 - "$tmp/deployments.json" <<'PY' >"$tmp/deployments.txt" +import json, sys +for value in json.load(open(sys.argv[1], encoding="utf-8")): + print(value) +PY + rollout_rc=0 + : >"$tmp/rollout.out" + : >"$tmp/rollout.err" + while IFS= read -r deployment; do + [ -n "$deployment" ] || continue + timeout ${params.waitTimeoutSeconds} kubectl -n ${params.target.namespace} rollout status "deployment/$deployment" --timeout=${params.waitTimeoutSeconds}s >>"$tmp/rollout.out" 2>>"$tmp/rollout.err" || rollout_rc=$? + done <"$tmp/deployments.txt" +else + rollout_rc=1 + : >"$tmp/rollout.out" + printf '%s\n' 'rollout skipped because manifest apply failed' >"$tmp/rollout.err" +fi +python3 - "$secret_get_rc" "$secret_check_rc" "$apply_rc" "$rollout_rc" "$tmp/secret-check.json" "$tmp/secret.err" "$tmp/apply.out" "$tmp/apply.err" "$tmp/rollout.out" "$tmp/rollout.err" <<'PY' +import json, sys +secret_get_rc, secret_check_rc, apply_rc, rollout_rc = [int(value) for value in sys.argv[1:5]] +def text(path, limit): + try: + return open(path, encoding="utf-8", errors="replace").read()[-limit:] + except FileNotFoundError: + return "" +try: + secret = json.load(open(sys.argv[5], encoding="utf-8")) +except Exception: + secret = {"ok": False, "name": "${params.secretName}", "requiredKeys": ${requiredSecretKeys}, "presentKeys": [], "missingKeys": ${requiredSecretKeys}, "valuesPrinted": False} +payload = { + "ok": secret_check_rc == 0 and apply_rc == 0 and rollout_rc == 0, + "target": "${params.target.id}", + "namespace": "${params.target.namespace}", + "secret": secret, + "steps": { + "secret": {"exitCode": secret_get_rc, "stderrTail": text(sys.argv[6], 2000)}, + "apply": {"exitCode": apply_rc, "stdoutTail": text(sys.argv[7], 6000), "stderrTail": text(sys.argv[8], 4000)}, + "rollout": {"exitCode": rollout_rc, "stdoutTail": text(sys.argv[9], 6000), "stderrTail": text(sys.argv[10], 4000)}, + }, + "valuesPrinted": False, +} +print(json.dumps(payload, ensure_ascii=False, indent=2)) +sys.exit(0 if payload["ok"] else 1) +PY +`; +} + +export function publicServiceStatusScript(params: { + target: PublicServiceTarget; + resources: PublicServiceRuntimeResources; + healthPath: string; + servicePort: number; +}): string { + const requiredSecretKeys = JSON.stringify(params.resources.requiredSecretKeys); + 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" + printf '%s' "$?" >"$tmp/$name.rc" +} +capture_json app kubectl -n ${params.target.namespace} get deployment ${params.resources.appDeploymentName} +capture_json frpc kubectl -n ${params.target.namespace} get deployment ${params.target.publicExposure.frpc.deploymentName} +capture_json service kubectl -n ${params.target.namespace} get service ${params.resources.serviceName} +capture_json pvc kubectl -n ${params.target.namespace} get pvc ${params.resources.persistentVolumeClaimName} +capture_json configmap kubectl -n ${params.target.namespace} get configmap ${params.resources.configMapName} +capture_json secret kubectl -n ${params.target.namespace} get secret ${params.resources.secretName} +capture_json endpoints kubectl -n ${params.target.namespace} get endpoints ${params.resources.serviceName} +${params.resources.sharedNetworkPolicyName === undefined ? "" : `capture_json networkpolicy kubectl -n ${params.target.namespace} get networkpolicy ${params.resources.sharedNetworkPolicyName}`} +python3 - "$tmp" '${requiredSecretKeys}' <<'PY' +import json, os, sys +tmp, required_json = sys.argv[1:] +required = json.loads(required_json) +def rc(name): + try: + return int(open(os.path.join(tmp, f"{name}.rc"), encoding="utf-8").read() or "1") + except Exception: + return 1 +def load(name): + try: + return json.load(open(os.path.join(tmp, f"{name}.json"), encoding="utf-8")) + except Exception: + return None +def deployment(name): + item = load(name) or {} + spec, status = item.get("spec", {}), item.get("status", {}) + desired = int(spec.get("replicas", 0) or 0) + ready = int(status.get("readyReplicas", 0) or 0) + return {"present": rc(name) == 0, "desired": desired, "ready": ready, "available": int(status.get("availableReplicas", 0) or 0), "ok": rc(name) == 0 and desired > 0 and ready == desired} +secret_item = load("secret") or {} +secret_data = secret_item.get("data", {}) if isinstance(secret_item.get("data", {}), dict) else {} +present_keys = sorted(key for key in required if isinstance(secret_data.get(key), str) and len(secret_data[key]) > 0) +missing_keys = sorted(set(required) - set(present_keys)) +pvc = load("pvc") or {} +endpoints = load("endpoints") or {} +addresses = sum(len(subset.get("addresses", []) or []) for subset in endpoints.get("subsets", []) or []) +app = deployment("app") +frpc = deployment("frpc") +payload = { + "ok": app["ok"] and frpc["ok"] and rc("service") == 0 and rc("pvc") == 0 and pvc.get("status", {}).get("phase") == "Bound" and rc("configmap") == 0 and rc("secret") == 0 and not missing_keys and addresses > 0${params.resources.sharedNetworkPolicyName === undefined ? "" : ' and rc("networkpolicy") == 0'}, + "target": "${params.target.id}", + "namespace": "${params.target.namespace}", + "deployments": {"app": app, "frpc": frpc}, + "service": {"name": "${params.resources.serviceName}", "present": rc("service") == 0, "endpointAddresses": addresses}, + "persistentVolumeClaim": {"name": "${params.resources.persistentVolumeClaimName}", "present": rc("pvc") == 0, "phase": pvc.get("status", {}).get("phase")}, + "configMap": {"name": "${params.resources.configMapName}", "present": rc("configmap") == 0}, + "secret": {"name": "${params.resources.secretName}", "present": rc("secret") == 0, "requiredKeys": sorted(required), "presentKeys": present_keys, "missingKeys": missing_keys, "valuesPrinted": False}, + "sharedNetworkPolicy": {"name": ${params.resources.sharedNetworkPolicyName === undefined ? "None" : JSON.stringify(params.resources.sharedNetworkPolicyName)}, "present": ${params.resources.sharedNetworkPolicyName === undefined ? "None" : 'rc("networkpolicy") == 0'}, "managedByService": False}, + "health": {"path": "${params.healthPath}", "source": "deployment-readiness", "probed": False}, + "valuesPrinted": False, +} +print(json.dumps(payload, ensure_ascii=False, indent=2)) +sys.exit(0 if payload["ok"] else 1) +PY +`; +} + +function renderFrpcToml(exposure: PublicServiceExposure, token: string): string { + return [ + `serverAddr = "${exposure.frpc.serverAddr}"`, + `serverPort = ${exposure.frpc.serverPort}`, + "loginFailExit = true", + `auth.token = "${token}"`, + "", + "[[proxies]]", + `name = "${exposure.frpc.proxyName}"`, + 'type = "tcp"', + `localIP = "${exposure.frpc.localIP}"`, + `localPort = ${exposure.frpc.localPort}`, + `remotePort = ${exposure.frpc.remotePort}`, + "", + ].join("\n"); +} + +function indentYamlBlock(value: string, spaces: number): string { + const prefix = " ".repeat(spaces); + return value.split("\n").map((line) => `${prefix}${line}`).join("\n"); +} + export function escapeTomlString(value: string): string { return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\""); } diff --git a/scripts/src/platform-infra-sub2rank-config.ts b/scripts/src/platform-infra-sub2rank-config.ts new file mode 100644 index 00000000..e977a4e7 --- /dev/null +++ b/scripts/src/platform-infra-sub2rank-config.ts @@ -0,0 +1,437 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { relative } from "node:path"; +import { rootPath } from "./config"; +import type { PublicServiceExposure, PublicServiceTarget } from "./platform-infra-public-service"; +import { assertNoDuplicateYamlMappingKeys, createYamlFieldReader, readYamlRecord, resolveRepoPath } from "./platform-infra-ops-library"; + +export const sub2RankConfigPath = rootPath("config", "platform-infra", "sub2rank.yaml"); +export const sub2RankConfigLabel = "config/platform-infra/sub2rank.yaml"; + +const y = createYamlFieldReader(sub2RankConfigLabel); + +export interface Sub2RankConfig { + version: number; + kind: "platform-infra-sub2rank"; + metadata: { id: string; owner: string; relatedIssues: number[] }; + defaults: { targetId: string }; + application: { + sourceRoot: string; + configRef: string; + runtimeTarget: string; + configText: string; + configSha256: string; + sourceCommit: string; + sourceClean: boolean; + sourceRemotePresent: boolean; + configMatchesSourceCommit: boolean; + deploymentBlockPresent: boolean; + automaticCreditEnabled: boolean; + server: { listenPort: number; databasePath: string; envKeys: string[] }; + }; + image: { repository: string; tag: string; pullPolicy: "Always" | "IfNotPresent" | "Never" }; + targets: Sub2RankTarget[]; + runtime: { + sharedNetworkPolicyRef: { name: string; managedBySub2Rank: false }; + service: { name: string; port: number }; + configMap: { name: string; key: string; mountPath: string }; + command: string[]; + storage: { claimName: string; size: string; mountPath: string }; + secret: ResolvedSecretDeclaration & { configRef: string; declarationId: string; requiredTargetKeys: string[]; appEnvTargetKeys: string[] }; + probes: { healthPath: string; initialDelaySeconds: number; periodSeconds: number; timeoutSeconds: number; failureThreshold: number }; + rollout: { fieldManager: string; waitTimeoutSeconds: number }; + }; +} + +export interface Sub2RankTarget extends PublicServiceTarget { + enabled: boolean; + publicExposure: PublicServiceExposure & { + frpc: PublicServiceExposure["frpc"] & { + configMapName: string; + configKey: string; + tokenEnvName: string; + tokenTargetKey: string; + }; + }; +} + +interface ParsedTarget { + id: string; + route: string; + namespace: string; + enabled: boolean; + replicas: number; + exposure: Omit & { + frpc: Omit; + }; +} + +interface ResolvedSecretDeclaration { + targetId: string; + route: string; + namespace: string; + secretName: string; + mappings: Array<{ sourceRef: string; sourceKey: string; targetKey: string }>; +} + +export function readSub2RankConfig(): Sub2RankConfig { + const root = readYamlRecord>(sub2RankConfigPath, "platform-infra-sub2rank"); + const version = y.integerField(root, "version", ""); + if (version !== 1) throw new Error(`${sub2RankConfigLabel}.version must be 1`); + const metadataRecord = y.objectField(root, "metadata", ""); + const defaultsRecord = y.objectField(root, "defaults", ""); + const applicationRecord = y.objectField(root, "application", ""); + const imageRecord = y.objectField(root, "image", ""); + const runtimeRecord = y.objectField(root, "runtime", ""); + const defaults = { targetId: simpleId(y.stringField(defaultsRecord, "targetId", "defaults"), "defaults.targetId") }; + const application = parseApplication(applicationRecord); + const image = parseImage(imageRecord); + const runtimeBase = parseRuntime(runtimeRecord); + const secret = resolveSecretDeclaration(runtimeBase.secret.configRef, runtimeBase.secret.declarationId); + const targets = parseTargets(root.targets, secret); + if (!targets.some((target) => target.id === defaults.targetId)) throw new Error(`${sub2RankConfigLabel}.targets must include defaults.targetId ${defaults.targetId}`); + validateResolvedConfig(application, image, runtimeBase, secret, targets); + return { + version, + kind: "platform-infra-sub2rank", + metadata: { + id: y.stringField(metadataRecord, "id", "metadata"), + owner: y.stringField(metadataRecord, "owner", "metadata"), + relatedIssues: y.numberArrayField(metadataRecord, "relatedIssues", "metadata"), + }, + defaults, + application, + image, + targets, + runtime: { ...runtimeBase, secret: { ...runtimeBase.secret, ...secret } }, + }; +} + +export function resolveSub2RankTarget(config: Sub2RankConfig, targetId: string | null): Sub2RankTarget { + const selected = targetId ?? config.defaults.targetId; + const target = config.targets.find((item) => item.id === selected); + if (target === undefined) throw new Error(`unknown Sub2Rank target ${selected}; known targets: ${config.targets.map((item) => item.id).join(", ")}`); + if (!target.enabled) throw new Error(`Sub2Rank target ${selected} is disabled in ${sub2RankConfigLabel}`); + return target; +} + +function parseApplication(record: Record): Sub2RankConfig["application"] { + const sourceRoot = y.absolutePathField(record, "sourceRoot", "application"); + const configRef = y.absolutePathField(record, "configRef", "application"); + const runtimeTarget = simpleId(y.stringField(record, "runtimeTarget", "application"), "application.runtimeTarget"); + if (!configRef.startsWith(`${sourceRoot.replace(/\/+$/u, "")}/`)) throw new Error(`${sub2RankConfigLabel}.application.configRef must be inside application.sourceRoot`); + const configText = readFileSync(configRef, "utf8"); + assertNoDuplicateYamlMappingKeys(configText, configRef); + const app = y.asRecord(Bun.YAML.parse(configText), configRef); + if (app.kind !== "Sub2Rank") throw new Error(`${configRef}.kind must be Sub2Rank`); + const appRuntime = app.runtime; + if (typeof appRuntime !== "object" || appRuntime === null || Array.isArray(appRuntime)) throw new Error(`${configRef}.runtime must be an object`); + const serverTargets = (appRuntime as Record).serverTargets; + if (typeof serverTargets !== "object" || serverTargets === null || Array.isArray(serverTargets)) throw new Error(`${configRef}.runtime.serverTargets must be an object`); + const serverValue = (serverTargets as Record)[runtimeTarget]; + if (typeof serverValue !== "object" || serverValue === null || Array.isArray(serverValue)) throw new Error(`${configRef}.runtime.serverTargets.${runtimeTarget} must be an object`); + const server = serverValue as Record; + const lottery = app.lottery; + if (typeof lottery !== "object" || lottery === null || Array.isArray(lottery)) throw new Error(`${configRef}.lottery must be an object`); + const automaticCredit = (lottery as Record).automaticCredit; + if (typeof automaticCredit !== "object" || automaticCredit === null || Array.isArray(automaticCredit)) throw new Error(`${configRef}.lottery.automaticCredit must be an object`); + const automaticCreditEnabled = (automaticCredit as Record).enabled; + if (typeof automaticCreditEnabled !== "boolean") throw new Error(`${configRef}.lottery.automaticCredit.enabled must be a boolean`); + const sourceCommit = gitText(sourceRoot, ["rev-parse", "HEAD"], "resolve Sub2Rank source commit"); + if (!/^[a-f0-9]{40}$/u.test(sourceCommit)) throw new Error(`${sourceRoot} HEAD must resolve to a full Git commit`); + const statusShort = gitText(sourceRoot, ["status", "--porcelain"], "read Sub2Rank source status", true); + const remote = gitText(sourceRoot, ["remote"], "read Sub2Rank source remotes", true); + const configRelativePath = relative(sourceRoot, configRef).replaceAll("\\", "/"); + const committedConfig = gitText(sourceRoot, ["show", `${sourceCommit}:${configRelativePath}`], "read committed Sub2Rank application config", true, false); + return { + sourceRoot, + configRef, + runtimeTarget, + configText, + configSha256: createHash("sha256").update(configText).digest("hex"), + sourceCommit, + sourceClean: statusShort.length === 0, + sourceRemotePresent: remote.split(/\r?\n/u).some((value) => value.trim().length > 0), + configMatchesSourceCommit: committedConfig === configText.trimEnd(), + deploymentBlockPresent: app.deployment !== undefined, + automaticCreditEnabled, + server: { + listenPort: integerValue(server.listenPort, `${configRef}.runtime.serverTargets.${runtimeTarget}.listenPort`), + databasePath: absolutePathValue(server.databasePath, `${configRef}.runtime.serverTargets.${runtimeTarget}.databasePath`), + envKeys: ["adminTokenEnv", "sub2apiAdminEmailEnv", "sub2apiAdminPasswordEnv"].map((key) => envKeyValue(server[key], `${configRef}.runtime.serverTargets.${runtimeTarget}.${key}`)), + }, + }; +} + +function parseImage(record: Record): Sub2RankConfig["image"] { + const repository = y.stringField(record, "repository", "image"); + const tag = y.stringField(record, "tag", "image"); + const pullPolicy = y.enumField(record, "pullPolicy", "image", ["Always", "IfNotPresent", "Never"] as const); + if (!/^[A-Za-z0-9._:/-]+$/u.test(repository) || repository.endsWith("/")) throw new Error(`${sub2RankConfigLabel}.image.repository has an unsupported format`); + if (!/^[A-Za-z0-9._-]+$/u.test(tag)) throw new Error(`${sub2RankConfigLabel}.image.tag has an unsupported format`); + return { repository, tag, pullPolicy }; +} + +function parseRuntime(record: Record): Omit & { + secret: { configRef: string; declarationId: string; requiredTargetKeys: string[]; appEnvTargetKeys: string[] }; +} { + const service = y.objectField(record, "service", "runtime"); + const sharedNetworkPolicyRef = y.objectField(record, "sharedNetworkPolicyRef", "runtime"); + const configMap = y.objectField(record, "configMap", "runtime"); + const storage = y.objectField(record, "storage", "runtime"); + const secret = y.objectField(record, "secret", "runtime"); + const probes = y.objectField(record, "probes", "runtime"); + const rollout = y.objectField(record, "rollout", "runtime"); + const configRefValue = y.stringField(secret, "configRef", "runtime.secret"); + return { + sharedNetworkPolicyRef: { + name: y.kubernetesNameField(sharedNetworkPolicyRef, "name", "runtime.sharedNetworkPolicyRef"), + managedBySub2Rank: disabledField(sharedNetworkPolicyRef, "managedBySub2Rank", "runtime.sharedNetworkPolicyRef"), + }, + service: { name: y.kubernetesNameField(service, "name", "runtime.service"), port: y.portField(service, "port", "runtime.service") }, + configMap: { + name: y.kubernetesNameField(configMap, "name", "runtime.configMap"), + key: y.stringField(configMap, "key", "runtime.configMap"), + mountPath: y.absolutePathField(configMap, "mountPath", "runtime.configMap"), + }, + command: y.stringArrayField(record, "command", "runtime"), + storage: { + claimName: y.kubernetesNameField(storage, "claimName", "runtime.storage"), + size: storageSize(y.stringField(storage, "size", "runtime.storage")), + mountPath: y.absolutePathField(storage, "mountPath", "runtime.storage"), + }, + secret: { + configRef: configRefValue, + declarationId: simpleId(y.stringField(secret, "declarationId", "runtime.secret"), "runtime.secret.declarationId"), + requiredTargetKeys: y.stringArrayField(secret, "requiredTargetKeys", "runtime.secret").map((key) => envKeyValue(key, "runtime.secret.requiredTargetKeys")), + appEnvTargetKeys: y.stringArrayField(secret, "appEnvTargetKeys", "runtime.secret").map((key) => envKeyValue(key, "runtime.secret.appEnvTargetKeys")), + }, + probes: { + healthPath: y.apiPathField(probes, "healthPath", "runtime.probes"), + initialDelaySeconds: positiveInteger(probes, "initialDelaySeconds", "runtime.probes"), + periodSeconds: positiveInteger(probes, "periodSeconds", "runtime.probes"), + timeoutSeconds: positiveInteger(probes, "timeoutSeconds", "runtime.probes"), + failureThreshold: positiveInteger(probes, "failureThreshold", "runtime.probes"), + }, + rollout: { + fieldManager: simpleId(y.stringField(rollout, "fieldManager", "runtime.rollout"), "runtime.rollout.fieldManager"), + waitTimeoutSeconds: positiveInteger(rollout, "waitTimeoutSeconds", "runtime.rollout"), + }, + }; +} + +function parseTargets(value: unknown, secret: ResolvedSecretDeclaration): Sub2RankTarget[] { + const records = y.arrayOfRecords(value, "targets"); + const ids = new Set(); + return records.map((record, index) => { + const path = `targets[${index}]`; + const parsed = parseTarget(record, path); + if (ids.has(parsed.id)) throw new Error(`${sub2RankConfigLabel}.targets contains duplicate id ${parsed.id}`); + ids.add(parsed.id); + const mapping = secret.mappings.find((item) => item.targetKey === parsed.exposure.frpc.tokenTargetKey); + if (mapping === undefined) throw new Error(`${sub2RankConfigLabel}.${path}.publicExposure.frpc.tokenTargetKey is not provided by runtime.secret.declarationId`); + return { + id: parsed.id, + route: parsed.route, + namespace: parsed.namespace, + enabled: parsed.enabled, + replicas: parsed.replicas, + publicExposure: { + ...parsed.exposure, + frpc: { + ...parsed.exposure.frpc, + secretName: secret.secretName, + secretKey: mapping.targetKey, + tokenSourceRef: mapping.sourceRef, + tokenSourceKey: mapping.sourceKey, + }, + }, + }; + }); +} + +function parseTarget(record: Record, path: string): ParsedTarget { + const exposure = y.objectField(record, "publicExposure", path); + const dns = y.objectField(exposure, "dns", `${path}.publicExposure`); + const frpc = y.objectField(exposure, "frpc", `${path}.publicExposure`); + const pk01 = y.objectField(exposure, "pk01", `${path}.publicExposure`); + return { + id: simpleId(y.stringField(record, "id", path), `${path}.id`), + route: routeValue(y.stringField(record, "route", path), `${path}.route`), + namespace: y.kubernetesNameField(record, "namespace", path), + enabled: y.booleanField(record, "enabled", path), + replicas: positiveInteger(record, "replicas", path), + exposure: { + enabled: y.booleanField(exposure, "enabled", `${path}.publicExposure`), + publicBaseUrl: y.httpsUrlField(exposure, "publicBaseUrl", `${path}.publicExposure`), + dns: { + hostname: y.hostField(dns, "hostname", `${path}.publicExposure.dns`), + expectedA: y.hostField(dns, "expectedA", `${path}.publicExposure.dns`), + resolvers: y.stringArrayField(dns, "resolvers", `${path}.publicExposure.dns`), + }, + frpc: { + deploymentName: y.kubernetesNameField(frpc, "deploymentName", `${path}.publicExposure.frpc`), + configMapName: y.kubernetesNameField(frpc, "configMapName", `${path}.publicExposure.frpc`), + configKey: y.stringField(frpc, "configKey", `${path}.publicExposure.frpc`), + image: imageValue(y.stringField(frpc, "image", `${path}.publicExposure.frpc`), `${path}.publicExposure.frpc.image`), + serverAddr: y.hostField(frpc, "serverAddr", `${path}.publicExposure.frpc`), + serverPort: y.portField(frpc, "serverPort", `${path}.publicExposure.frpc`), + proxyName: simpleId(y.stringField(frpc, "proxyName", `${path}.publicExposure.frpc`), `${path}.publicExposure.frpc.proxyName`), + remotePort: y.portField(frpc, "remotePort", `${path}.publicExposure.frpc`), + localIP: y.hostField(frpc, "localIP", `${path}.publicExposure.frpc`), + localPort: y.portField(frpc, "localPort", `${path}.publicExposure.frpc`), + tokenEnvName: y.envKeyField(frpc, "tokenEnvName", `${path}.publicExposure.frpc`), + tokenTargetKey: y.envKeyField(frpc, "tokenTargetKey", `${path}.publicExposure.frpc`), + }, + pk01: { + route: routeValue(y.stringField(pk01, "route", `${path}.publicExposure.pk01`), `${path}.publicExposure.pk01.route`), + caddyConfigPath: y.absolutePathField(pk01, "caddyConfigPath", `${path}.publicExposure.pk01`), + caddyServiceName: simpleId(y.stringField(pk01, "caddyServiceName", `${path}.publicExposure.pk01`), `${path}.publicExposure.pk01.caddyServiceName`), + responseHeaderTimeoutSeconds: positiveInteger(pk01, "responseHeaderTimeoutSeconds", `${path}.publicExposure.pk01`), + }, + }, + }; +} + +function resolveSecretDeclaration(configRef: string, declarationId: string): ResolvedSecretDeclaration { + const path = resolveRepoPath(configRef); + const root = readYamlRecord>(path, "unidesk-secret-distribution"); + const declarations = Array.isArray(root.kubernetesSecrets) ? root.kubernetesSecrets : []; + const declaration = declarations.find((item) => typeof item === "object" && item !== null && !Array.isArray(item) && (item as Record).name === declarationId); + if (typeof declaration !== "object" || declaration === null || Array.isArray(declaration)) throw new Error(`${configRef} does not contain kubernetesSecrets name=${declarationId}`); + const item = declaration as Record; + const targetId = stringValue(item.targetId, `${configRef}.kubernetesSecrets[name=${declarationId}].targetId`); + const secretName = kubernetesNameValue(item.secretName, `${configRef}.kubernetesSecrets[name=${declarationId}].secretName`); + const targets = Array.isArray(root.targets) ? root.targets : []; + const target = targets.find((value) => typeof value === "object" && value !== null && !Array.isArray(value) && (value as Record).id === targetId); + if (typeof target !== "object" || target === null || Array.isArray(target)) throw new Error(`${configRef} does not contain targets id=${targetId}`); + const targetRecord = target as Record; + const data = Array.isArray(item.data) ? item.data : []; + const mappings = data.map((value, index) => { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${configRef}.kubernetesSecrets[name=${declarationId}].data[${index}] must be an object`); + const mapping = value as Record; + return { + sourceRef: sourceRefValue(mapping.sourceRef, `${configRef}.kubernetesSecrets[name=${declarationId}].data[${index}].sourceRef`), + sourceKey: envKeyValue(mapping.sourceKey, `${configRef}.kubernetesSecrets[name=${declarationId}].data[${index}].sourceKey`), + targetKey: envKeyValue(mapping.targetKey, `${configRef}.kubernetesSecrets[name=${declarationId}].data[${index}].targetKey`), + }; + }); + return { + targetId, + route: routeValue(stringValue(targetRecord.route, `${configRef}.targets[id=${targetId}].route`), `${configRef}.targets[id=${targetId}].route`), + namespace: kubernetesNameValue(targetRecord.namespace, `${configRef}.targets[id=${targetId}].namespace`), + secretName, + mappings, + }; +} + +function validateResolvedConfig( + application: Sub2RankConfig["application"], + image: Sub2RankConfig["image"], + runtime: ReturnType, + secret: ResolvedSecretDeclaration, + targets: Sub2RankTarget[], +): void { + if (!/^[a-f0-9]{40}$/u.test(image.tag) || image.tag !== application.sourceCommit) throw new Error(`${sub2RankConfigLabel}.image.tag must equal the full application source commit ${application.sourceCommit}`); + if (!application.sourceClean) throw new Error(`${sub2RankConfigLabel}.application.sourceRoot must be clean before rendering deployment config`); + if (!application.configMatchesSourceCommit) throw new Error(`${sub2RankConfigLabel}.application.configRef must match the file stored at application source commit ${application.sourceCommit}`); + const required = new Set(runtime.secret.requiredTargetKeys); + const provided = new Set(secret.mappings.map((item) => item.targetKey)); + const missing = [...required].filter((key) => !provided.has(key)); + if (missing.length > 0) throw new Error(`${sub2RankConfigLabel}.runtime.secret.requiredTargetKeys missing from ${runtime.secret.configRef} declaration ${runtime.secret.declarationId}: ${missing.join(", ")}`); + if (!sameStringSet(application.server.envKeys, runtime.secret.appEnvTargetKeys)) throw new Error(`${sub2RankConfigLabel}.runtime.secret.appEnvTargetKeys must match application runtime server env keys`); + if (application.server.listenPort !== runtime.service.port) throw new Error(`${sub2RankConfigLabel}.runtime.service.port must match application runtime server listenPort`); + if (!application.server.databasePath.startsWith(`${runtime.storage.mountPath.replace(/\/+$/u, "")}/`)) throw new Error(`${sub2RankConfigLabel}.runtime.storage.mountPath must contain the application databasePath`); + for (const target of targets) { + if (target.route !== secret.route || target.namespace !== secret.namespace) throw new Error(`${sub2RankConfigLabel}.targets[${target.id}] route/namespace must match runtime Secret target ${secret.targetId}`); + if (target.publicExposure.frpc.localPort !== runtime.service.port) throw new Error(`${sub2RankConfigLabel}.targets[${target.id}].publicExposure.frpc.localPort must match runtime.service.port`); + } +} + +function positiveInteger(record: Record, key: string, path: string): number { + const value = y.integerField(record, key, path); + if (value <= 0) throw new Error(`${sub2RankConfigLabel}.${path}.${key} must be > 0`); + return value; +} + +function disabledField(record: Record, key: string, path: string): false { + const value = y.booleanField(record, key, path); + if (value) throw new Error(`${sub2RankConfigLabel}.${path}.${key} must remain false because the resource is shared and owned outside Sub2Rank`); + return false; +} + +function storageSize(value: string): string { + if (!/^[1-9][0-9]*(?:Mi|Gi|Ti)$/u.test(value)) throw new Error(`${sub2RankConfigLabel}.runtime.storage.size must use Mi, Gi, or Ti`); + return value; +} + +function simpleId(value: string, path: string): string { + if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${sub2RankConfigLabel}.${path} must be a simple id`); + return value; +} + +function routeValue(value: string, path: string): string { + if (!/^[A-Za-z0-9:_./-]+$/u.test(value)) throw new Error(`${path} has an unsupported route format`); + return value; +} + +function imageValue(value: string, path: string): string { + if (!/^[A-Za-z0-9._:/-]+:[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${sub2RankConfigLabel}.${path} must be an image reference with tag`); + return value; +} + +function integerValue(value: unknown, path: string): number { + if (!Number.isInteger(value)) throw new Error(`${path} must be an integer`); + return Number(value); +} + +function absolutePathValue(value: unknown, path: string): string { + const text = stringValue(value, path); + if (!text.startsWith("/") || text.includes("..")) throw new Error(`${path} must be an absolute path without ..`); + return text; +} + +function envKeyValue(value: unknown, path: string): string { + const text = stringValue(value, path); + if (!/^[A-Z_][A-Z0-9_]*$/u.test(text)) throw new Error(`${path} must be an environment key`); + return text; +} + +function sourceRefValue(value: unknown, path: string): string { + const text = stringValue(value, path); + if (text.startsWith("/") || text.includes("..") || !/^[A-Za-z0-9._/-]+$/u.test(text)) throw new Error(`${path} must be a relative sourceRef`); + return text; +} + +function kubernetesNameValue(value: unknown, path: string): string { + const text = stringValue(value, path); + if (!/^[a-z0-9](?:[-a-z0-9.]*[a-z0-9])?$/u.test(text)) throw new Error(`${path} must be a Kubernetes name`); + return text; +} + +function stringValue(value: unknown, path: string): string { + if (typeof value !== "string" || value.length === 0) throw new Error(`${path} must be a non-empty string`); + return value; +} + +function sameStringSet(left: string[], right: string[]): boolean { + return left.length === right.length && [...left].sort().every((value, index) => value === [...right].sort()[index]); +} + +function gitText( + cwd: string, + args: string[], + label: string, + allowEmpty = false, + trim = true, +): string { + const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" }); + if (result.exitCode !== 0) { + const stderr = new TextDecoder().decode(result.stderr).replace(/\s+/gu, " ").trim().slice(0, 500); + throw new Error(`${label} failed (exit ${result.exitCode}): ${stderr}`); + } + const output = new TextDecoder().decode(result.stdout); + const value = trim ? output.trim() : output.trimEnd(); + if (!allowEmpty && value.length === 0) throw new Error(`${label} returned no output`); + return value; +} diff --git a/scripts/src/platform-infra-sub2rank.ts b/scripts/src/platform-infra-sub2rank.ts new file mode 100644 index 00000000..f4ebfa74 --- /dev/null +++ b/scripts/src/platform-infra-sub2rank.ts @@ -0,0 +1,431 @@ +import { createHash } from "node:crypto"; +import type { UniDeskConfig } from "./config"; +import { startJob } from "./jobs"; +import { + applyManifestWithExistingSecretScript, + applyPk01CaddyBlock, + capture, + compactCapture, + dryRunManifestScript, + parseJsonOutput, + publicDnsProbe, + publicHttpProbe, + publicServicePolicyChecks, + publicServiceStatusScript, + renderEnvSecretFrpcManifest, + type PublicServiceRuntimeResources, +} from "./platform-infra-public-service"; +import { parseOpsApplyOptions, parseOpsCommonOptions, type OpsApplyOptions, type OpsCommonOptions } from "./platform-infra-ops-library"; +import { readSub2RankConfig, resolveSub2RankTarget, sub2RankConfigLabel, type Sub2RankConfig, type Sub2RankTarget } from "./platform-infra-sub2rank-config"; + +const serviceId = "sub2rank"; + +export function sub2RankHelp(): Record { + return { + command: "platform-infra sub2rank plan|apply|status|validate", + output: "json", + usage: [ + "bun scripts/cli.ts platform-infra sub2rank plan [--target NC01]", + "bun scripts/cli.ts platform-infra sub2rank apply [--target NC01] --dry-run", + "bun scripts/cli.ts platform-infra sub2rank apply [--target NC01] --confirm", + "bun scripts/cli.ts platform-infra sub2rank status [--target NC01] [--full|--raw]", + "bun scripts/cli.ts platform-infra sub2rank validate [--target NC01] [--full|--raw]", + ], + configTruth: sub2RankConfigLabel, + applicationConfigRef: "/root/sub2rank/config/sub2rank.yaml", + artifactPolicy: "Consumes the prebuilt image declared by owning YAML; apply never runs Docker or builds from a host worktree.", + secretPolicy: "Resolves the existing Secret declaration from config/secrets-distribution.yaml and never reads or prints runtime Secret values.", + }; +} + +export async function runSub2RankCommand(config: UniDeskConfig, args: string[]): Promise> { + const [action = "plan"] = args; + if (action === "plan") return plan(parseOpsCommonOptions(args.slice(1))); + if (action === "apply") return await apply(config, parseOpsApplyOptions(args.slice(1))); + if (action === "status") return await status(config, parseOpsCommonOptions(args.slice(1))); + if (action === "validate") return await validate(config, parseOpsCommonOptions(args.slice(1))); + return { ok: false, error: "unsupported-platform-infra-sub2rank-command", args, help: sub2RankHelp() }; +} + +function plan(options: OpsCommonOptions): Record { + const sub2rank = readSub2RankConfig(); + const target = resolveSub2RankTarget(sub2rank, options.targetId); + const yaml = renderManifest(sub2rank, target); + const policy = policyChecks(sub2rank, target, yaml); + return { + ok: policy.every((item) => item.ok), + action: "platform-infra-sub2rank-plan", + mutation: false, + config: configSummary(sub2rank, target), + policy, + artifact: { + image: `${sub2rank.image.repository}:${sub2rank.image.tag}`, + buildDisposition: "not-performed-by-apply", + sourceRoot: sub2rank.application.sourceRoot, + sourceCommit: sub2rank.application.sourceCommit, + sourceClean: sub2rank.application.sourceClean, + sourceRemotePresent: sub2rank.application.sourceRemotePresent, + publication: sub2rank.application.sourceRemotePresent + ? { supported: false, blocker: "remote-source-not-registered-with-controlled-ci", required: "Register the repository as a YAML-owned PaC/Tekton source authority before publishing the image." } + : { supported: false, blocker: "remote-source-authority-missing", required: "Create or select an independent Git remote source authority, then register it with the YAML-owned PaC/Tekton producer." }, + }, + topology: `client -> PK01 Caddy -> PK01 frps:${target.publicExposure.frpc.remotePort} -> ${target.id} frpc -> ${sub2rank.runtime.service.name}.${target.namespace}.svc.cluster.local:${sub2rank.runtime.service.port}`, + next: { + secrets: "bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope sub2rank --confirm", + dryRun: `bun scripts/cli.ts platform-infra sub2rank apply --target ${target.id} --dry-run`, + apply: `bun scripts/cli.ts platform-infra sub2rank apply --target ${target.id} --confirm`, + status: `bun scripts/cli.ts platform-infra sub2rank status --target ${target.id}`, + validate: `bun scripts/cli.ts platform-infra sub2rank validate --target ${target.id}`, + }, + }; +} + +async function apply(config: UniDeskConfig, options: OpsApplyOptions): Promise> { + const sub2rank = readSub2RankConfig(); + const target = resolveSub2RankTarget(sub2rank, options.targetId); + const yaml = renderManifest(sub2rank, target); + const policy = policyChecks(sub2rank, target, yaml); + if (!policy.every((item) => item.ok)) { + return { ok: false, action: "platform-infra-sub2rank-apply", mode: "policy-blocked", mutation: false, target: targetSummary(target), policy }; + } + if (options.confirm && !options.wait) { + const job = startJob( + `platform_infra_sub2rank_apply_${target.id.toLowerCase()}`, + ["bun", "scripts/cli.ts", "platform-infra", "sub2rank", "apply", "--target", target.id, "--confirm", "--wait"], + `Apply ${target.id} Sub2Rank manifests and reconcile the YAML-owned PK01 Caddy site through the controlled UniDesk CLI`, + ); + return { + ok: true, + action: "platform-infra-sub2rank-apply", + mode: "async-job", + mutation: true, + target: targetSummary(target), + 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`, + runtime: `bun scripts/cli.ts platform-infra sub2rank status --target ${target.id}`, + validate: `bun scripts/cli.ts platform-infra sub2rank validate --target ${target.id}`, + }, + }; + } + if (options.dryRun) { + const remote = await capture(config, target.route, ["sh"], dryRunManifestScript({ + yaml, + target, + fieldManager: sub2rank.runtime.rollout.fieldManager, + manifestName: serviceId, + })); + const parsed = parseJsonOutput(remote.stdout); + return { + ok: remote.exitCode === 0 && parsed?.ok === true, + action: "platform-infra-sub2rank-apply", + mode: "dry-run", + mutation: false, + target: targetSummary(target), + policy, + remote: parsed ?? compactCapture(remote, { full: true }), + }; + } + const resources = runtimeResources(sub2rank, target); + const remote = await capture(config, target.route, ["sh"], applyManifestWithExistingSecretScript({ + yaml, + target, + fieldManager: sub2rank.runtime.rollout.fieldManager, + manifestName: serviceId, + secretName: resources.secretName, + requiredSecretKeys: resources.requiredSecretKeys, + deploymentNames: [resources.appDeploymentName, target.publicExposure.frpc.deploymentName], + waitTimeoutSeconds: sub2rank.runtime.rollout.waitTimeoutSeconds, + })); + const parsed = parseJsonOutput(remote.stdout); + const remoteOk = remote.exitCode === 0 && parsed?.ok === true; + const caddy = remoteOk + ? await applyPk01CaddyBlock(config, serviceId, target.publicExposure) + : { ok: false, mutation: false, disposition: "skipped-runtime-apply-failed" }; + return { + ok: remoteOk && caddy.ok === true, + action: "platform-infra-sub2rank-apply", + mode: "confirmed", + mutation: true, + target: targetSummary(target), + policy, + secret: secretSummary(sub2rank), + remote: parsed ?? compactCapture(remote, { full: true }), + pk01Caddy: caddy, + next: { + status: `bun scripts/cli.ts platform-infra sub2rank status --target ${target.id}`, + validate: `bun scripts/cli.ts platform-infra sub2rank validate --target ${target.id}`, + }, + }; +} + +async function status(config: UniDeskConfig, options: OpsCommonOptions): Promise> { + const sub2rank = readSub2RankConfig(); + const target = resolveSub2RankTarget(sub2rank, options.targetId); + const resources = runtimeResources(sub2rank, target); + const remote = await capture(config, target.route, ["sh"], publicServiceStatusScript({ + target, + resources, + healthPath: sub2rank.runtime.probes.healthPath, + servicePort: sub2rank.runtime.service.port, + })); + const parsed = parseJsonOutput(remote.stdout); + return { + ok: remote.exitCode === 0 && parsed?.ok === true, + action: "platform-infra-sub2rank-status", + mutation: false, + target: targetSummary(target), + configRefs: configRefsSummary(sub2rank), + summary: parsed, + remote: compactCapture(remote, { full: options.full || remote.exitCode !== 0 }), + ...(options.raw ? { raw: remote } : {}), + }; +} + +async function validate(config: UniDeskConfig, options: OpsCommonOptions): Promise> { + const sub2rank = readSub2RankConfig(); + const target = resolveSub2RankTarget(sub2rank, options.targetId); + const runtime = await status(config, options); + const dns = await publicDnsProbe(target.publicExposure.dns); + const publicHttps = publicHttpProbe(target.publicExposure.publicBaseUrl, sub2rank.runtime.probes.healthPath); + return { + ok: runtime.ok === true && dns.ok === true && publicHttps.ok === true, + action: "platform-infra-sub2rank-validate", + mutation: false, + target: targetSummary(target), + runtime, + dns, + publicHttps, + automaticCredit: { enabled: sub2rank.application.automaticCreditEnabled, source: sub2rank.application.configRef }, + }; +} + +function renderManifest(config: Sub2RankConfig, target: Sub2RankTarget): string { + const runtime = config.runtime; + const image = `${config.image.repository}:${config.image.tag}`; + const configHash = createHash("sha256").update(JSON.stringify({ deployment: config, target, appConfigSha256: config.application.configSha256 })).digest("hex").slice(0, 16); + const appEnv = runtime.secret.appEnvTargetKeys.map((key) => ` - name: ${key}\n valueFrom:\n secretKeyRef:\n name: ${runtime.secret.secretName}\n key: ${key}`).join("\n"); + const command = runtime.command.map((value) => ` - ${yamlQuoted(value)}`).join("\n"); + return `apiVersion: v1 +kind: ConfigMap +metadata: + name: ${runtime.configMap.name} + namespace: ${target.namespace} + labels: + app.kubernetes.io/name: ${runtime.service.name} + app.kubernetes.io/part-of: platform-infra + app.kubernetes.io/managed-by: unidesk +data: + ${runtime.configMap.key}: | +${indent(config.application.configText, 4)} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: ${runtime.storage.claimName} + namespace: ${target.namespace} + labels: + app.kubernetes.io/name: ${runtime.service.name} + app.kubernetes.io/part-of: platform-infra + app.kubernetes.io/managed-by: unidesk +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: ${runtime.storage.size} +--- +apiVersion: v1 +kind: Service +metadata: + name: ${runtime.service.name} + namespace: ${target.namespace} + labels: + app.kubernetes.io/name: ${runtime.service.name} + app.kubernetes.io/part-of: platform-infra + app.kubernetes.io/managed-by: unidesk +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: ${runtime.service.name} + ports: + - name: http + port: ${runtime.service.port} + targetPort: http +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${runtime.service.name} + namespace: ${target.namespace} + labels: + app.kubernetes.io/name: ${runtime.service.name} + app.kubernetes.io/part-of: platform-infra + app.kubernetes.io/managed-by: unidesk +spec: + replicas: ${target.replicas} + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: ${runtime.service.name} + template: + metadata: + labels: + app.kubernetes.io/name: ${runtime.service.name} + app.kubernetes.io/part-of: platform-infra + annotations: + unidesk.ai/sub2rank-config-sha256: "${config.application.configSha256}" + unidesk.ai/sub2rank-deployment-hash: "${configHash}" + unidesk.ai/public-base-url: "${target.publicExposure.publicBaseUrl}" + spec: + securityContext: + fsGroup: 1000 + containers: + - name: ${runtime.service.name} + image: ${image} + imagePullPolicy: ${config.image.pullPolicy} + command: +${command} + ports: + - name: http + containerPort: ${runtime.service.port} + env: +${appEnv} + readinessProbe: + httpGet: + path: ${runtime.probes.healthPath} + port: http + initialDelaySeconds: ${runtime.probes.initialDelaySeconds} + periodSeconds: ${runtime.probes.periodSeconds} + timeoutSeconds: ${runtime.probes.timeoutSeconds} + failureThreshold: ${runtime.probes.failureThreshold} + livenessProbe: + httpGet: + path: ${runtime.probes.healthPath} + port: http + initialDelaySeconds: ${runtime.probes.initialDelaySeconds} + periodSeconds: ${runtime.probes.periodSeconds} + timeoutSeconds: ${runtime.probes.timeoutSeconds} + failureThreshold: ${runtime.probes.failureThreshold} + volumeMounts: + - name: app-config + mountPath: ${runtime.configMap.mountPath} + subPath: ${runtime.configMap.key} + readOnly: true + - name: data + mountPath: ${runtime.storage.mountPath} + volumes: + - name: app-config + configMap: + name: ${runtime.configMap.name} + - name: data + persistentVolumeClaim: + claimName: ${runtime.storage.claimName} +${renderEnvSecretFrpcManifest(target, { + configMapName: target.publicExposure.frpc.configMapName, + configKey: target.publicExposure.frpc.configKey, + tokenSecretName: runtime.secret.secretName, + tokenSecretKey: target.publicExposure.frpc.tokenTargetKey, + tokenEnvName: target.publicExposure.frpc.tokenEnvName, + })}`; +} + +function policyChecks(config: Sub2RankConfig, target: Sub2RankTarget, yaml: string): Array<{ name: string; ok: boolean; detail: string }> { + return [ + ...publicServicePolicyChecks(yaml, target, serviceId, { requireAllowAllManifest: false }).map((item) => ({ name: String(item.name), ok: item.ok === true, detail: String(item.detail) })), + { + name: "shared-runtime-resources-not-owned", + ok: !/^\s*kind:\s*(?:Namespace|NetworkPolicy)\s*$/mu.test(yaml) && config.runtime.sharedNetworkPolicyRef.managedBySub2Rank === false, + detail: `Sub2Rank references ${target.namespace}/NetworkPolicy/${config.runtime.sharedNetworkPolicyRef.name} but does not apply or seize field ownership of shared runtime resources.`, + }, + { + name: "deployment-owned-by-unidesk", + ok: !config.application.deploymentBlockPresent, + detail: `Deployment, image, Secret, FRP and Caddy facts belong only to ${sub2RankConfigLabel}; the application config must not contain a deployment block.`, + }, + { + name: "existing-secret-source-ref", + ok: config.runtime.secret.requiredTargetKeys.every((key) => config.runtime.secret.mappings.some((mapping) => mapping.targetKey === key)), + detail: `Runtime keys resolve through ${config.runtime.secret.configRef} declaration ${config.runtime.secret.declarationId}; valuesPrinted=false.`, + }, + ]; +} + +function configSummary(config: Sub2RankConfig, target: Sub2RankTarget): Record { + return { + configRefs: configRefsSummary(config), + target: targetSummary(target), + image: `${config.image.repository}:${config.image.tag}`, + runtime: { + service: `${config.runtime.service.name}.${target.namespace}.svc.cluster.local:${config.runtime.service.port}`, + storage: { claimName: config.runtime.storage.claimName, size: config.runtime.storage.size, mountPath: config.runtime.storage.mountPath }, + secret: secretSummary(config), + }, + publicExposure: { + publicBaseUrl: target.publicExposure.publicBaseUrl, + expectedA: target.publicExposure.dns.expectedA, + remotePort: target.publicExposure.frpc.remotePort, + marker: serviceId, + }, + automaticCredit: { enabled: config.application.automaticCreditEnabled, source: config.application.configRef }, + }; +} + +function configRefsSummary(config: Sub2RankConfig): Record { + return { + deployment: { path: sub2RankConfigLabel, kind: config.kind }, + application: { + path: config.application.configRef, + sha256: config.application.configSha256, + deploymentBlockPresent: config.application.deploymentBlockPresent, + sourceCommit: config.application.sourceCommit, + sourceClean: config.application.sourceClean, + sourceRemotePresent: config.application.sourceRemotePresent, + configMatchesSourceCommit: config.application.configMatchesSourceCommit, + }, + secret: { path: config.runtime.secret.configRef, declarationId: config.runtime.secret.declarationId }, + }; +} + +function secretSummary(config: Sub2RankConfig): Record { + return { + name: config.runtime.secret.secretName, + declarationId: config.runtime.secret.declarationId, + mappings: config.runtime.secret.mappings.map((item) => ({ sourceRef: item.sourceRef, sourceKey: item.sourceKey, targetKey: item.targetKey })), + requiredTargetKeys: config.runtime.secret.requiredTargetKeys, + valuesPrinted: false, + }; +} + +function targetSummary(target: Sub2RankTarget): Record { + return { + id: target.id, + route: target.route, + namespace: target.namespace, + replicas: target.replicas, + publicBaseUrl: target.publicExposure.publicBaseUrl, + }; +} + +function runtimeResources(config: Sub2RankConfig, target: Sub2RankTarget): PublicServiceRuntimeResources { + return { + appDeploymentName: config.runtime.service.name, + serviceName: config.runtime.service.name, + persistentVolumeClaimName: config.runtime.storage.claimName, + configMapName: config.runtime.configMap.name, + secretName: config.runtime.secret.secretName, + requiredSecretKeys: config.runtime.secret.requiredTargetKeys, + sharedNetworkPolicyName: config.runtime.sharedNetworkPolicyRef.name, + }; +} + +function indent(value: string, spaces: number): string { + const prefix = " ".repeat(spaces); + return value.trimEnd().split("\n").map((line) => `${prefix}${line}`).join("\n"); +} + +function yamlQuoted(value: string): string { + return JSON.stringify(value); +} diff --git a/scripts/src/platform-infra/entry.ts b/scripts/src/platform-infra/entry.ts index 9a2a414f..a31e195c 100644 --- a/scripts/src/platform-infra/entry.ts +++ b/scripts/src/platform-infra/entry.ts @@ -391,7 +391,7 @@ export interface ManagedResourceCleanupPlan { export function platformInfraHelp(): unknown { const target = sub2ApiHelpTargetSummary(); return { - command: "platform-infra sub2api|langbot|n8n|webterm|wechat-archive|observability|secret-plane|kafka|gitea|pipelines-as-code ...", + command: "platform-infra sub2api|sub2rank|langbot|n8n|webterm|wechat-archive|observability|secret-plane|kafka|gitea|pipelines-as-code ...", output: "json", usage: [ "bun scripts/cli.ts platform-infra sub2api plan [--target G14|D601]", @@ -406,6 +406,11 @@ export function platformInfraHelp(): unknown { "bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id ", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-probe --account unidesk-codex-hy --confirm", + "bun scripts/cli.ts platform-infra sub2rank plan [--target NC01]", + "bun scripts/cli.ts platform-infra sub2rank apply [--target NC01] --dry-run", + "bun scripts/cli.ts platform-infra sub2rank apply [--target NC01] --confirm", + "bun scripts/cli.ts platform-infra sub2rank status [--target NC01] [--full|--raw]", + "bun scripts/cli.ts platform-infra sub2rank validate [--target NC01] [--full|--raw]", "bun scripts/cli.ts platform-infra egress-proxy traffic --target D601 --sample-seconds 15", "bun scripts/cli.ts platform-infra egress-proxy k3s-build-benchmark --targets D601,D518 --profile no-mirror-600m --dry-run", "bun scripts/cli.ts platform-infra langbot plan [--target G14]", diff --git a/scripts/src/platform-infra/options.ts b/scripts/src/platform-infra/options.ts index 8ac373f6..db3923da 100644 --- a/scripts/src/platform-infra/options.ts +++ b/scripts/src/platform-infra/options.ts @@ -38,6 +38,10 @@ export function sub2ApiHelpTargetSummary(): Record { export async function runPlatformInfraCommand(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { const [target, action] = args; + if (target === "sub2rank") { + const { runSub2RankCommand } = await import("../platform-infra-sub2rank"); + return await runSub2RankCommand(config, args.slice(1)); + } if (target === "egress-proxy") { const { runPlatformInfraEgressProxyCommand } = await import("../platform-infra-egress-proxy"); return await runPlatformInfraEgressProxyCommand(config, args.slice(1));