Files
pikasTech-unidesk/scripts/src/platform-infra-sub2rank-config.ts
T
2026-07-13 08:49:18 +02:00

608 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Sub2RankTarget["publicExposure"], "frpc"> & {
frpc: Omit<Sub2RankTarget["publicExposure"]["frpc"], "secretName" | "secretKey" | "tokenSourceRef" | "tokenSourceKey">;
};
}
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<Record<string, unknown>>(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<string, unknown>): 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<string, unknown>).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<string, unknown>)[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<string, unknown>;
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<string, unknown>).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<string, unknown>).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<string, unknown>): 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<string, unknown>): 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<string, unknown>): Omit<Sub2RankConfig["runtime"], "secret"> & {
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<string>();
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<string, unknown>, 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<Record<string, unknown>>(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<string, unknown>).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<string, unknown>;
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<string, unknown>).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<string, unknown>;
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<string, unknown>;
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<typeof parseRuntime>,
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=falseArgo 自动同步当前关闭");
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<string, unknown>, 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<string, unknown>, 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;
}