324 lines
16 KiB
TypeScript
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;
|
|
}
|