608 lines
33 KiB
TypeScript
608 lines
33 KiB
TypeScript
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=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<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;
|
||
}
|