390 lines
20 KiB
TypeScript
390 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 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 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`);
|
|
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,
|
|
},
|
|
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;
|
|
}
|