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

324 lines
16 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 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 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 deliveryTargets = Object.fromEntries(Object.entries(deliveryRecords).map(([id, value]) => [id, parseDeliveryTarget(id, record(value, `delivery.targets.${id}`))]));
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 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 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`);
if (text(value.node, `${path}.node`) !== id) throw new Error(`${path}.node must match target id ${id}`);
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`) !== 4317) throw new Error(`${path}.deployment.publicExposure.hostPort must be 4317`);
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`);
return {
id,
node: id,
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`),
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`),
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 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 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;
}