Files
pikasTech-unidesk/scripts/src/selfmedia-config.ts
T

410 lines
20 KiB
TypeScript

import { readFileSync } from "node:fs";
import { rootPath } from "./config";
export const selfMediaConfigPath = rootPath("config", "selfmedia.yaml");
export const selfMediaConfigRef = "config/selfmedia.yaml";
export interface SelfMediaDeliveryTarget {
readonly id: string;
readonly node: string;
readonly lane: string;
readonly route: string;
readonly ci: {
readonly namespace: string;
readonly pipeline: string;
readonly pipelineRunPrefix: string;
readonly serviceAccountName: string;
readonly serviceAccountAutomount: false;
readonly roleBindingName: string;
readonly parameterBinding: "repository" | "literal";
readonly workspaceSize: string;
readonly pipelineTimeout: string;
readonly toolImage: string;
readonly buildkitImage: string;
};
readonly source: {
readonly repository: string;
readonly worktreeRemote: string;
readonly branch: string;
readonly readUrl: string;
readonly snapshotPrefix: string;
};
readonly build: Record<string, unknown> & {
readonly dockerfile: string;
readonly imageRepository: string;
readonly networkMode: string;
readonly envReuse: {
readonly enabled: true;
readonly mode: "buildkit-state-host-path";
readonly statePath: string;
readonly identityFiles: readonly string[];
readonly semanticPackageManifests: {
readonly policyFile: string;
readonly renderer: string;
};
};
readonly proxy: Record<string, unknown>;
};
readonly gitops: Record<string, unknown> & {
readonly readUrl: string;
readonly writeUrl: string;
readonly branch: string;
readonly manifestPath: string;
readonly releaseStatePath: string;
readonly credentialSecretName: string;
readonly credentialTokenKey: string;
readonly credentialUsername: string;
};
readonly deployment: Record<string, unknown>;
}
export interface SelfMediaCutoverTarget {
readonly id: string;
readonly mode: "host-process-to-kubernetes";
readonly source: {
readonly workspace: string;
readonly publicAddress: string;
readonly healthUrl: string;
readonly stopCommand: readonly string[];
readonly startCommand: readonly string[];
readonly dataPaths: readonly string[];
readonly excludes: readonly string[];
};
readonly destination: {
readonly route: string;
readonly namespace: string;
readonly application: string;
readonly publicAddress: string;
readonly healthUrl: string;
readonly dataClaim: string;
readonly codexClaim: string;
};
readonly prepare: {
readonly requiresFinalConfirmation: true;
readonly phases: readonly string[];
};
readonly rollback: {
readonly phases: readonly string[];
readonly dataPolicy: string;
};
}
interface SelfMediaConfig {
readonly defaultTargetId: string;
readonly deliveryTargets: Readonly<Record<string, SelfMediaDeliveryTarget>>;
readonly cutoverTargets: Readonly<Record<string, SelfMediaCutoverTarget>>;
}
export function readSelfMediaConfig(): SelfMediaConfig {
const root = record(Bun.YAML.parse(readFileSync(selfMediaConfigPath, "utf8")), selfMediaConfigRef);
if (integer(root.version, "version") !== 1) throw new Error(`${selfMediaConfigRef}.version must be 1`);
if (text(root.kind, "kind") !== "selfmedia-platform-delivery") throw new Error(`${selfMediaConfigRef}.kind must be selfmedia-platform-delivery`);
const defaults = record(root.defaults, "defaults");
const delivery = record(root.delivery, "delivery");
const cutover = record(root.cutover, "cutover");
const deliveryRecords = record(delivery.targets, "delivery.targets");
const cutoverRecords = record(cutover.targets, "cutover.targets");
const resolvedDeliveryRecords = resolveDeliveryRecords(deliveryRecords);
const deliveryTargets = Object.fromEntries(Object.entries(resolvedDeliveryRecords).map(([id, value]) => [id, parseDeliveryTarget(id, value)]));
const cutoverTargets = Object.fromEntries(Object.entries(cutoverRecords).map(([id, value]) => [id, parseCutoverTarget(id, record(value, `cutover.targets.${id}`))]));
const defaultTargetId = text(defaults.targetId, "defaults.targetId");
if (deliveryTargets[defaultTargetId] === undefined || cutoverTargets[defaultTargetId] === undefined) {
throw new Error(`${selfMediaConfigRef}.defaults.targetId must resolve in delivery.targets and cutover.targets`);
}
return { defaultTargetId, deliveryTargets, cutoverTargets };
}
export function resolveSelfMediaDeliveryTarget(targetId?: string | null): SelfMediaDeliveryTarget {
const config = readSelfMediaConfig();
const id = targetId ?? config.defaultTargetId;
const target = config.deliveryTargets[id];
if (target === undefined) throw new Error(`unknown selfmedia delivery target ${id}; known targets: ${Object.keys(config.deliveryTargets).join(", ")}`);
return target;
}
export function resolveSelfMediaDeliveryTargetByConfigRef(configRef: string): SelfMediaDeliveryTarget {
const prefix = `${selfMediaConfigRef}#delivery.targets.`;
if (!configRef.startsWith(prefix)) throw new Error(`selfmedia delivery configRef must start with ${prefix}`);
return resolveSelfMediaDeliveryTarget(configRef.slice(prefix.length));
}
export function resolveSelfMediaCutoverTarget(targetId?: string | null): SelfMediaCutoverTarget {
const config = readSelfMediaConfig();
const id = targetId ?? config.defaultTargetId;
const target = config.cutoverTargets[id];
if (target === undefined) throw new Error(`unknown selfmedia cutover target ${id}; known targets: ${Object.keys(config.cutoverTargets).join(", ")}`);
return target;
}
export function selfMediaDeliveryConfigRef(targetId: string): string {
return `${selfMediaConfigRef}#delivery.targets.${targetId}`;
}
export function selfMediaEffectiveDeployment(target: SelfMediaDeliveryTarget): Record<string, unknown> {
return {
...structuredClone(target.deployment),
delivery: {
enabled: true,
source: {
branch: target.source.branch,
snapshotPrefix: target.source.snapshotPrefix,
readUrl: target.source.readUrl,
},
build: structuredClone(target.build),
gitops: structuredClone(target.gitops),
},
};
}
function parseDeliveryTarget(id: string, value: Record<string, unknown>): SelfMediaDeliveryTarget {
const path = `delivery.targets.${id}`;
const ci = record(value.ci, `${path}.ci`);
const source = record(value.source, `${path}.source`);
const build = record(value.build, `${path}.build`);
const envReuse = record(build.envReuse, `${path}.build.envReuse`);
const semanticPackageManifests = record(envReuse.semanticPackageManifests, `${path}.build.envReuse.semanticPackageManifests`);
const proxy = record(build.proxy, `${path}.build.proxy`);
const gitops = record(value.gitops, `${path}.gitops`);
const deployment = record(value.deployment, `${path}.deployment`);
const target = record(deployment.target, `${path}.deployment.target`);
const publicExposure = record(deployment.publicExposure, `${path}.deployment.publicExposure`);
const noProxy = stringArray(proxy.noProxy, `${path}.build.proxy.noProxy`);
if (!noProxy.includes("hyueapi.com") || !noProxy.includes(".hyueapi.com")) throw new Error(`${path}.build.proxy.noProxy must preserve hyueapi.com and .hyueapi.com`);
const node = text(value.node, `${path}.node`);
if (text(target.id, `${path}.deployment.target.id`) !== id) throw new Error(`${path}.deployment.target.id must match ${id}`);
if (text(source.repository, `${path}.source.repository`) !== "pikainc/selfmedia") throw new Error(`${path}.source.repository must be pikainc/selfmedia`);
if (!text(source.snapshotPrefix, `${path}.source.snapshotPrefix`).startsWith("refs/unidesk/snapshots/")) throw new Error(`${path}.source.snapshotPrefix must be immutable`);
if (boolean(ci.serviceAccountAutomount, `${path}.ci.serviceAccountAutomount`) !== false) throw new Error(`${path}.ci.serviceAccountAutomount must be false`);
if (boolean(publicExposure.enabled, `${path}.deployment.publicExposure.enabled`) !== true) throw new Error(`${path}.deployment.publicExposure.enabled must be true`);
if (integer(publicExposure.hostPort, `${path}.deployment.publicExposure.hostPort`) <= 0) throw new Error(`${path}.deployment.publicExposure.hostPort must be positive`);
validateAccessSecret(deployment, `${path}.deployment`);
const gitopsWriteUrl = text(gitops.writeUrl, `${path}.gitops.writeUrl`);
if (!gitopsWriteUrl.startsWith("http://gitea-http.")) throw new Error(`${path}.gitops.writeUrl must use internal Gitea authority`);
if (boolean(envReuse.enabled, `${path}.build.envReuse.enabled`) !== true) throw new Error(`${path}.build.envReuse.enabled must be true`);
const envReuseMode = text(envReuse.mode, `${path}.build.envReuse.mode`);
if (envReuseMode !== "buildkit-state-host-path") throw new Error(`${path}.build.envReuse.mode must be buildkit-state-host-path`);
const identityFiles = stringArray(envReuse.identityFiles, `${path}.build.envReuse.identityFiles`)
.map((item, index) => relativePath(item, `${path}.build.envReuse.identityFiles[${index}]`));
if (identityFiles.length === 0 || new Set(identityFiles).size !== identityFiles.length) throw new Error(`${path}.build.envReuse.identityFiles must be non-empty and unique`);
const packageManifestPolicyFile = relativePath(
semanticPackageManifests.policyFile,
`${path}.build.envReuse.semanticPackageManifests.policyFile`,
);
const packageManifestRenderer = relativePath(
semanticPackageManifests.renderer,
`${path}.build.envReuse.semanticPackageManifests.renderer`,
);
if (packageManifestPolicyFile === packageManifestRenderer) {
throw new Error(`${path}.build.envReuse.semanticPackageManifests paths must be distinct`);
}
return {
id,
node,
lane: text(value.lane, `${path}.lane`),
route: text(value.route, `${path}.route`),
ci: {
namespace: kubernetesName(ci.namespace, `${path}.ci.namespace`),
pipeline: kubernetesName(ci.pipeline, `${path}.ci.pipeline`),
pipelineRunPrefix: kubernetesName(ci.pipelineRunPrefix, `${path}.ci.pipelineRunPrefix`),
serviceAccountName: kubernetesName(ci.serviceAccountName, `${path}.ci.serviceAccountName`),
serviceAccountAutomount: false,
roleBindingName: kubernetesName(ci.roleBindingName, `${path}.ci.roleBindingName`),
parameterBinding: enumValue(ci.parameterBinding, `${path}.ci.parameterBinding`, ["repository", "literal"] as const),
workspaceSize: text(ci.workspaceSize, `${path}.ci.workspaceSize`),
pipelineTimeout: text(ci.pipelineTimeout, `${path}.ci.pipelineTimeout`),
toolImage: text(ci.toolImage, `${path}.ci.toolImage`),
buildkitImage: text(ci.buildkitImage, `${path}.ci.buildkitImage`),
},
source: {
repository: "pikainc/selfmedia",
worktreeRemote: url(source.worktreeRemote, `${path}.source.worktreeRemote`),
branch: text(source.branch, `${path}.source.branch`),
readUrl: url(source.readUrl, `${path}.source.readUrl`),
snapshotPrefix: text(source.snapshotPrefix, `${path}.source.snapshotPrefix`),
},
build: {
...structuredClone(build),
dockerfile: relativePath(build.dockerfile, `${path}.build.dockerfile`),
imageRepository: text(build.imageRepository, `${path}.build.imageRepository`),
networkMode: text(build.networkMode, `${path}.build.networkMode`),
envReuse: {
enabled: true,
mode: "buildkit-state-host-path",
statePath: absolutePath(envReuse.statePath, `${path}.build.envReuse.statePath`),
identityFiles,
semanticPackageManifests: {
policyFile: packageManifestPolicyFile,
renderer: packageManifestRenderer,
},
},
proxy: structuredClone(proxy),
},
gitops: {
...structuredClone(gitops),
readUrl: url(gitops.readUrl, `${path}.gitops.readUrl`),
writeUrl: url(gitops.writeUrl, `${path}.gitops.writeUrl`),
branch: text(gitops.branch, `${path}.gitops.branch`),
manifestPath: relativePath(gitops.manifestPath, `${path}.gitops.manifestPath`),
releaseStatePath: relativePath(gitops.releaseStatePath, `${path}.gitops.releaseStatePath`),
credentialSecretName: kubernetesName(gitops.credentialSecretName, `${path}.gitops.credentialSecretName`),
credentialTokenKey: text(gitops.credentialTokenKey, `${path}.gitops.credentialTokenKey`),
credentialUsername: text(gitops.credentialUsername, `${path}.gitops.credentialUsername`),
},
deployment: structuredClone(deployment),
};
}
function resolveDeliveryRecords(records: Record<string, unknown>): Record<string, Record<string, unknown>> {
const resolved: Record<string, Record<string, unknown>> = {};
const resolving = new Set<string>();
const resolveOne = (id: string): Record<string, unknown> => {
if (resolved[id] !== undefined) return resolved[id];
if (resolving.has(id)) throw new Error(`${selfMediaConfigRef}.delivery.targets.${id}.extends contains a cycle`);
const raw = record(records[id], `delivery.targets.${id}`);
resolving.add(id);
const parentId = raw.extends === undefined ? null : text(raw.extends, `delivery.targets.${id}.extends`);
const own = Object.fromEntries(Object.entries(raw).filter(([key]) => key !== "extends"));
const effective = parentId === null ? structuredClone(own) : deepMerge(resolveOne(parentId), own);
resolving.delete(id);
resolved[id] = effective;
return effective;
};
for (const id of Object.keys(records)) resolveOne(id);
return resolved;
}
function deepMerge(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
const result = structuredClone(base);
for (const [key, value] of Object.entries(override)) {
const current = result[key];
result[key] = isPlainRecord(current) && isPlainRecord(value) ? deepMerge(current, value) : structuredClone(value);
}
return result;
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function validateAccessSecret(deployment: Record<string, unknown>, path: string): void {
const secrets = record(deployment.secrets, `${path}.secrets`);
const access = record(secrets.access, `${path}.secrets.access`);
const target = record(access.target, `${path}.secrets.access.target`);
absolutePath(access.sourceRef, `${path}.secrets.access.sourceRef`);
kubernetesName(target.secretName, `${path}.secrets.access.target.secretName`);
if (boolean(target.readOnly, `${path}.secrets.access.target.readOnly`) !== true) {
throw new Error(`${selfMediaConfigRef}.${path}.secrets.access.target.readOnly must be true`);
}
if (!Array.isArray(target.files) || target.files.length === 0) throw new Error(`${selfMediaConfigRef}.${path}.secrets.access.target.files must be a non-empty array`);
const targetKeys = new Set<string>();
const mountPaths = new Set<string>();
target.files.forEach((value, index) => {
const file = record(value, `${path}.secrets.access.target.files[${index}]`);
const targetKey = text(file.targetKey, `${path}.secrets.access.target.files[${index}].targetKey`);
const mountPath = absolutePath(file.mountPath, `${path}.secrets.access.target.files[${index}].mountPath`);
if (targetKeys.has(targetKey)) throw new Error(`${selfMediaConfigRef}.${path}.secrets.access.target.files contains duplicate targetKey ${targetKey}`);
if (mountPaths.has(mountPath)) throw new Error(`${selfMediaConfigRef}.${path}.secrets.access.target.files contains duplicate mountPath ${mountPath}`);
targetKeys.add(targetKey);
mountPaths.add(mountPath);
});
}
function parseCutoverTarget(id: string, value: Record<string, unknown>): SelfMediaCutoverTarget {
const path = `cutover.targets.${id}`;
const source = record(value.source, `${path}.source`);
const destination = record(value.destination, `${path}.destination`);
const prepare = record(value.prepare, `${path}.prepare`);
const rollback = record(value.rollback, `${path}.rollback`);
const mode = text(value.mode, `${path}.mode`);
if (mode !== "host-process-to-kubernetes") throw new Error(`${path}.mode must be host-process-to-kubernetes`);
if (boolean(prepare.requiresFinalConfirmation, `${path}.prepare.requiresFinalConfirmation`) !== true) throw new Error(`${path}.prepare.requiresFinalConfirmation must be true`);
return {
id,
mode,
source: {
workspace: absolutePath(source.workspace, `${path}.source.workspace`),
publicAddress: url(source.publicAddress, `${path}.source.publicAddress`),
healthUrl: url(source.healthUrl, `${path}.source.healthUrl`),
stopCommand: stringArray(source.stopCommand, `${path}.source.stopCommand`),
startCommand: stringArray(source.startCommand, `${path}.source.startCommand`),
dataPaths: stringArray(source.dataPaths, `${path}.source.dataPaths`).map((item, index) => relativePath(item, `${path}.source.dataPaths[${index}]`)),
excludes: stringArray(source.excludes, `${path}.source.excludes`).map((item, index) => relativePath(item, `${path}.source.excludes[${index}]`)),
},
destination: {
route: text(destination.route, `${path}.destination.route`),
namespace: kubernetesName(destination.namespace, `${path}.destination.namespace`),
application: kubernetesName(destination.application, `${path}.destination.application`),
publicAddress: url(destination.publicAddress, `${path}.destination.publicAddress`),
healthUrl: url(destination.healthUrl, `${path}.destination.healthUrl`),
dataClaim: kubernetesName(destination.dataClaim, `${path}.destination.dataClaim`),
codexClaim: kubernetesName(destination.codexClaim, `${path}.destination.codexClaim`),
},
prepare: {
requiresFinalConfirmation: true,
phases: stringArray(prepare.phases, `${path}.prepare.phases`),
},
rollback: {
phases: stringArray(rollback.phases, `${path}.rollback.phases`),
dataPolicy: text(rollback.dataPolicy, `${path}.rollback.dataPolicy`),
},
};
}
function record(value: unknown, path: string): Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${selfMediaConfigRef}.${path} must be an object`);
return value as Record<string, unknown>;
}
function text(value: unknown, path: string): string {
if (typeof value !== "string" || value.length === 0 || value.includes("\n")) throw new Error(`${selfMediaConfigRef}.${path} must be a non-empty single-line string`);
return value;
}
function integer(value: unknown, path: string): number {
if (!Number.isInteger(value)) throw new Error(`${selfMediaConfigRef}.${path} must be an integer`);
return value as number;
}
function boolean(value: unknown, path: string): boolean {
if (typeof value !== "boolean") throw new Error(`${selfMediaConfigRef}.${path} must be a boolean`);
return value;
}
function enumValue<const T extends string>(value: unknown, path: string, allowed: readonly T[]): T {
const result = text(value, path);
if (!allowed.includes(result as T)) throw new Error(`${selfMediaConfigRef}.${path} must be one of ${allowed.join(", ")}`);
return result as T;
}
function stringArray(value: unknown, path: string): string[] {
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0 || item.includes("\n"))) throw new Error(`${selfMediaConfigRef}.${path} must be an array of non-empty single-line strings`);
return [...value] as string[];
}
function kubernetesName(value: unknown, path: string): string {
const result = text(value, path);
if (!/^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/u.test(result) || result.length > 63) throw new Error(`${selfMediaConfigRef}.${path} must be a Kubernetes name`);
return result;
}
function absolutePath(value: unknown, path: string): string {
const result = text(value, path);
if (!result.startsWith("/") || result.split("/").includes("..")) throw new Error(`${selfMediaConfigRef}.${path} must be an absolute path without ..`);
return result;
}
function relativePath(value: unknown, path: string): string {
const result = text(value, path);
if (result.startsWith("/") || result.split("/").includes("..")) throw new Error(`${selfMediaConfigRef}.${path} must be a relative path without ..`);
return result;
}
function url(value: unknown, path: string): string {
const result = text(value, path);
const parsed = new URL(result);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`${selfMediaConfigRef}.${path} must use http or https`);
if (parsed.username.length > 0 || parsed.password.length > 0) throw new Error(`${selfMediaConfigRef}.${path} must not contain credentials`);
return result;
}