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 & { 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; }; 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 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 { 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 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): Record> { const resolved: Record> = {}; const resolving = new Set(); const resolveOne = (id: string): Record => { 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, override: Record): Record { 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 { return value !== null && typeof value === "object" && !Array.isArray(value); } 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 enumValue(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; }