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 & { readonly dockerfile: string; readonly imageRepository: string; readonly networkMode: string; readonly proxy: Record; }; readonly gitops: Record & { 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; } 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>; readonly cutoverTargets: Readonly>; } 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 { 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): 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, 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(); const mountPaths = new Set(); 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): 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 { if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${selfMediaConfigRef}.${path} must be an object`); return value as Record; } 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; }