import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { dirname, relative, resolve } 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"; warnings: string[]; metadata: { id: string; owner: string; relatedIssues: number[] }; defaults: { targetId: string }; application: { repository: string; remote: string; branch: string; sourceRoot: string; configRef: string; sourceConfigPath: string; dockerfile: string; runtimeTarget: string; configText: string; configSha256: string; sourceCommit: string; sourceClean: boolean; sourceRemotePresent: boolean; sourceRemoteMatches: boolean; sourceConfigPathMatches: boolean; configMatchesSourceCommit: boolean; deploymentBlockPresent: boolean; automaticCreditEnabled: boolean; server: { listenPort: number; databasePath: string; envKeys: string[] }; }; image: { repository: string; pullPolicy: "Always" | "IfNotPresent" | "Never" }; delivery: { enabled: boolean; pipeline: { namespace: string; name: string; runPrefix: string; serviceAccountName: string; timeout: string; workspaceSize: string; toolsImage: string; buildkitImage: string; sourceSnapshotPrefix: string; }; build: { networkMode: "host"; proxy: { http: string; https: string; all: string; noProxy: string[] }; }; gitops: { readUrl: string; writeUrl: string; branch: string; manifestPath: string; releaseStatePath: string; maxPushAttempts: number; author: { name: string; email: string }; application: { name: string; namespace: string; project: string; path: string; destinationNamespace: string; automated: boolean; }; }; }; 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 deliveryRecord = y.objectField(root, "delivery", ""); 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 delivery = parseDelivery(deliveryRecord); 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}`); const warnings = validateResolvedConfig(application, image, delivery, runtimeBase, secret, targets); return { version, kind: "platform-infra-sub2rank", warnings, metadata: { id: y.stringField(metadataRecord, "id", "metadata"), owner: y.stringField(metadataRecord, "owner", "metadata"), relatedIssues: y.numberArrayField(metadataRecord, "relatedIssues", "metadata"), }, defaults, application, image, delivery, 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 repository = repositoryValue(y.stringField(record, "repository", "application"), "application.repository"); const remote = y.stringField(record, "remote", "application"); const branch = simpleId(y.stringField(record, "branch", "application"), "application.branch"); const sourceRoot = y.absolutePathField(record, "sourceRoot", "application"); const configRef = y.absolutePathField(record, "configRef", "application"); const sourceConfigPath = safeRelativePath(y.stringField(record, "sourceConfigPath", "application"), "application.sourceConfigPath"); const dockerfile = safeRelativePath(y.stringField(record, "dockerfile", "application"), "application.dockerfile"); 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`); if (!existsSync(resolve(sourceRoot, dockerfile))) throw new Error(`${sub2RankConfigLabel}.application.dockerfile does not exist under 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 observedRemote = gitText(sourceRoot, ["remote", "get-url", "origin"], "read Sub2Rank origin", true); const configRelativePath = relative(sourceRoot, configRef).replaceAll("\\", "/"); const committedConfig = gitText(sourceRoot, ["show", `${sourceCommit}:${configRelativePath}`], "read committed Sub2Rank application config", true, false); return { repository, remote, branch, sourceRoot, configRef, sourceConfigPath, dockerfile, runtimeTarget, configText, configSha256: createHash("sha256").update(configText).digest("hex"), sourceCommit, sourceClean: statusShort.length === 0, sourceRemotePresent: observedRemote.length > 0, sourceRemoteMatches: sameRepositoryIdentity(remote, observedRemote), sourceConfigPathMatches: configRelativePath === sourceConfigPath, 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 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`); return { repository, pullPolicy }; } function parseDelivery(record: Record): Sub2RankConfig["delivery"] { const pipeline = y.objectField(record, "pipeline", "delivery"); const build = y.objectField(record, "build", "delivery"); const proxy = y.objectField(build, "proxy", "delivery.build"); const gitops = y.objectField(record, "gitops", "delivery"); const author = y.objectField(gitops, "author", "delivery.gitops"); const application = y.objectField(gitops, "application", "delivery.gitops"); const timeout = y.stringField(pipeline, "timeout", "delivery.pipeline"); if (!/^[1-9][0-9]*s$/u.test(timeout)) throw new Error(`${sub2RankConfigLabel}.delivery.pipeline.timeout must be positive seconds`); const sourceSnapshotPrefix = y.stringField(pipeline, "sourceSnapshotPrefix", "delivery.pipeline"); if (!/^refs\/[A-Za-z0-9._/-]+$/u.test(sourceSnapshotPrefix) || sourceSnapshotPrefix.includes("..")) throw new Error(`${sub2RankConfigLabel}.delivery.pipeline.sourceSnapshotPrefix must be a safe refs/ prefix`); const email = y.stringField(author, "email", "delivery.gitops.author"); if (!/^[^@\s]+@[^@\s]+$/u.test(email)) throw new Error(`${sub2RankConfigLabel}.delivery.gitops.author.email must be an email address`); const automated = y.booleanField(application, "automated", "delivery.gitops.application"); return { enabled: y.booleanField(record, "enabled", "delivery"), pipeline: { namespace: y.kubernetesNameField(pipeline, "namespace", "delivery.pipeline"), name: y.kubernetesNameField(pipeline, "name", "delivery.pipeline"), runPrefix: y.kubernetesNameField(pipeline, "runPrefix", "delivery.pipeline"), serviceAccountName: y.kubernetesNameField(pipeline, "serviceAccountName", "delivery.pipeline"), timeout, workspaceSize: storageSize(y.stringField(pipeline, "workspaceSize", "delivery.pipeline")), toolsImage: imageValue(y.stringField(pipeline, "toolsImage", "delivery.pipeline"), "delivery.pipeline.toolsImage"), buildkitImage: imageValue(y.stringField(pipeline, "buildkitImage", "delivery.pipeline"), "delivery.pipeline.buildkitImage"), sourceSnapshotPrefix, }, build: { networkMode: y.enumField(build, "networkMode", "delivery.build", ["host"] as const), proxy: { http: httpUrlValue(y.stringField(proxy, "http", "delivery.build.proxy"), "delivery.build.proxy.http"), https: httpUrlValue(y.stringField(proxy, "https", "delivery.build.proxy"), "delivery.build.proxy.https"), all: httpUrlValue(y.stringField(proxy, "all", "delivery.build.proxy"), "delivery.build.proxy.all"), noProxy: y.stringArrayField(proxy, "noProxy", "delivery.build.proxy"), }, }, gitops: { readUrl: httpUrlValue(y.stringField(gitops, "readUrl", "delivery.gitops"), "delivery.gitops.readUrl"), writeUrl: httpUrlValue(y.stringField(gitops, "writeUrl", "delivery.gitops"), "delivery.gitops.writeUrl"), branch: simpleId(y.stringField(gitops, "branch", "delivery.gitops"), "delivery.gitops.branch"), manifestPath: safeRelativePath(y.stringField(gitops, "manifestPath", "delivery.gitops"), "delivery.gitops.manifestPath"), releaseStatePath: safeRelativePath(y.stringField(gitops, "releaseStatePath", "delivery.gitops"), "delivery.gitops.releaseStatePath"), maxPushAttempts: positiveInteger(gitops, "maxPushAttempts", "delivery.gitops"), author: { name: y.stringField(author, "name", "delivery.gitops.author"), email }, application: { name: y.kubernetesNameField(application, "name", "delivery.gitops.application"), namespace: y.kubernetesNameField(application, "namespace", "delivery.gitops.application"), project: y.kubernetesNameField(application, "project", "delivery.gitops.application"), path: safeRelativePath(y.stringField(application, "path", "delivery.gitops.application"), "delivery.gitops.application.path"), destinationNamespace: y.kubernetesNameField(application, "destinationNamespace", "delivery.gitops.application"), automated, }, }, }; } 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"], delivery: Sub2RankConfig["delivery"], runtime: ReturnType, secret: ResolvedSecretDeclaration, targets: Sub2RankTarget[], ): string[] { const warnings: string[] = []; if (!application.sourceConfigPathMatches) warnings.push("application.sourceConfigPath 与 application.configRef 的相对路径不一致"); if (!application.configMatchesSourceCommit) warnings.push(`application.configRef 与源码提交 ${application.sourceCommit} 中的文件不一致`); if (!application.sourceRemoteMatches) warnings.push("application.remote 与 application.sourceRoot 的 origin 不一致"); if (!sameRepositoryIdentity(application.remote, `https://github.com/${application.repository}.git`)) warnings.push("application.repository 与 application.remote 指向的仓库不一致"); if (!delivery.enabled) warnings.push("delivery.enabled=false,自动交付当前关闭"); if (delivery.gitops.maxPushAttempts > 5) warnings.push("delivery.gitops.maxPushAttempts 大于 5,失败时可能延长流水线耗时"); if (dirname(delivery.gitops.manifestPath).replaceAll("\\", "/") !== delivery.gitops.application.path) warnings.push("delivery.gitops.manifestPath 不在 application.path 的直接子路径下"); if (!delivery.build.proxy.noProxy.includes("hyueapi.com") || !delivery.build.proxy.noProxy.includes(".hyueapi.com")) throw new Error(`${sub2RankConfigLabel}.delivery.build.proxy.noProxy must retain hyueapi.com and .hyueapi.com`); if (!image.repository.startsWith("127.0.0.1:5000/")) warnings.push("image.repository 未使用 NC01 本地 registry"); 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)) warnings.push("runtime.secret.appEnvTargetKeys 与应用声明的环境变量集合不一致"); if (application.server.listenPort !== runtime.service.port) warnings.push("runtime.service.port 与应用 listenPort 不一致"); if (!application.server.databasePath.startsWith(`${runtime.storage.mountPath.replace(/\/+$/u, "")}/`)) warnings.push("应用 databasePath 不在 runtime.storage.mountPath 下"); if (!delivery.gitops.application.automated) warnings.push("delivery.gitops.application.automated=false,Argo 自动同步当前关闭"); if (application.deploymentBlockPresent) warnings.push("应用配置仍包含 deployment 段;部署事实应以 platform-infra YAML 为准"); for (const target of targets) { if (target.route !== secret.route || target.namespace !== secret.namespace) warnings.push(`targets[${target.id}] 的 route/namespace 与 Secret 目标 ${secret.targetId} 不一致`); if (target.publicExposure.frpc.localPort !== runtime.service.port) warnings.push(`targets[${target.id}].publicExposure.frpc.localPort 与 runtime.service.port 不一致`); if (target.namespace !== delivery.gitops.application.destinationNamespace) warnings.push(`targets[${target.id}].namespace 与 Argo destinationNamespace 不一致`); } return warnings; } 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 safeRelativePath(value: string, path: string): string { if (value.startsWith("/") || value.split(/[\\/]/u).includes("..") || !/^[A-Za-z0-9._/-]+$/u.test(value)) throw new Error(`${sub2RankConfigLabel}.${path} must be a safe relative path`); return value; } function repositoryValue(value: string, path: string): string { if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(value)) throw new Error(`${sub2RankConfigLabel}.${path} must be owner/repository`); return value; } function httpUrlValue(value: string, path: string): string { let parsed: URL; try { parsed = new URL(value); } catch { throw new Error(`${sub2RankConfigLabel}.${path} must be an HTTP URL`); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`${sub2RankConfigLabel}.${path} must be an HTTP URL`); if (parsed.username.length > 0 || parsed.password.length > 0 || parsed.hash.length > 0) throw new Error(`${sub2RankConfigLabel}.${path} must not contain credentials or a fragment`); return value; } function sameRepositoryIdentity(left: string, right: string): boolean { const identity = (value: string): string | null => { const scp = /^[^/@\s]+@([^:\s]+):(.+)$/u.exec(value.trim()); let host = scp?.[1] ?? ""; let path = scp?.[2] ?? ""; if (host.length === 0 || path.length === 0) { try { const parsed = new URL(value.trim()); host = parsed.hostname; path = parsed.pathname; } catch { return null; } } const parts = path.replace(/^\/+|\/+$/gu, "").split("/"); if (parts.length !== 2) return null; return `${host.toLowerCase()}/${parts[0]?.toLowerCase()}/${parts[1]?.replace(/\.git$/iu, "").toLowerCase()}`; }; const leftIdentity = identity(left); return leftIdentity !== null && leftIdentity === identity(right); } 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; }