import type { KubernetesObject, V1Container, V1ContainerPort, V1ConfigMap, V1Deployment, V1Ingress, V1Namespace, V1PersistentVolumeClaim, V1PodSpec, V1Secret, V1Service, V1ServicePort, V1Volume, V1VolumeMount, } from "@kubernetes/client-node"; import { createHash } from "node:crypto"; import { basename } from "node:path"; import type { ComposeSpecification } from "../schema/docker.d"; import type { Service } from "../schema/docker.d"; import { LABELS } from "../const"; import { getComposeArchPlacement } from "./arch"; import { isPostgresVolumeEntry } from "./database"; import { toEnvVars } from "./format"; import { deepMerge } from "./shared"; import { isS3VolumeEntry } from "./storage"; import { LocalArtifactProvider, type ArtifactProvider, } from "../shared/artifacts"; type NormalizedMount = { name: string; kind: "bind" | "config-file" | "volume" | "tmpfs"; target: string; source?: string; readOnly?: boolean; configKey?: string; subPath?: string; sizeLimit?: string; requestedStorage?: string; diskTag?: string[]; replicaCount?: number; dataLocality?: "none"; }; type NormalizedPort = { name: string; containerPort: number; servicePort: number; hostPort?: number; protocol: "TCP" | "UDP"; host?: string; routingKind?: "ingress" | "ingressroute"; paths?: string[]; }; type ServiceVolume = NonNullable[number]; type ComposeVolumes = ComposeSpecification["volumes"]; type LonghornStoragePolicy = { diskTag?: string[]; replicaCount?: number; dataLocality?: "none"; }; type LonghornStorageClass = KubernetesObject & { provisioner: "driver.longhorn.io"; allowVolumeExpansion: boolean; reclaimPolicy: "Delete"; volumeBindingMode: "Immediate"; parameters: Record; }; const DefaultNamedVolumeStorage = { requestedStorage: "1Gi", diskTag: ["fast"], replicaCount: 2, dataLocality: "none" as const, }; function getLonghornStoragePolicy( mount: NormalizedMount, ): LonghornStoragePolicy | undefined { if ( !mount.diskTag && mount.replicaCount === undefined && !mount.dataLocality ) { return; } return { diskTag: mount.diskTag ? [...mount.diskTag].sort() : undefined, replicaCount: mount.replicaCount, dataLocality: mount.dataLocality, }; } function toLonghornStorageClassName(policy: LonghornStoragePolicy): string { const digest = createHash("sha256") .update(JSON.stringify(policy)) .digest("hex") .slice(0, 12); return `kuber-longhorn-${digest}`; } function toPortNumber(value: number | string | undefined): number | undefined { if (typeof value === "number") return value; if (!value || value.includes("-")) return; const port = Number(value); return Number.isInteger(port) ? port : undefined; } export type ReplicaRange = { kind: "range"; min: number; max: number }; export type ReplicaFixed = { kind: "fixed"; value: number }; export type ReplicaSpec = ReplicaRange | ReplicaFixed; const REPLICA_RANGE_PATTERN = /^\s*(\d+)\s*-\s*(\d+)\s*$/; export function parseReplicaRange(value: unknown): ReplicaRange | undefined { if (typeof value !== "string" && typeof value !== "number") return; const text = String(value).trim(); const match = REPLICA_RANGE_PATTERN.exec(text); if (!match) return; const min = Number(match[1]); const max = Number(match[2]); if (!Number.isInteger(min) || !Number.isInteger(max)) return; if (min < 1 || max < 1) { throw new Error( `Invalid replica range ${text}. Range bounds must be positive integers.`, ); } if (min > max) { throw new Error( `Invalid replica range ${text}. Minimum (${min}) cannot exceed maximum (${max}).`, ); } return { kind: "range", min, max }; } function assertNumericString( source: string, value: string | undefined, ): void { if (value === undefined || value.trim() === "") return; const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < 0) { throw new Error( `Invalid ${source} value ${JSON.stringify(value)}. Expected a non-negative integer (zero allowed for a fixed replica count), a numeric string, or a "min-max" range.`, ); } } export function toReplicaCount(service: Service): number { if (service.scale !== undefined) { const scale = toPortNumber(service.scale); if (scale !== undefined) { assertNumericString("scale", String(service.scale)); return scale; } assertNumericString("scale", String(service.scale)); } if (service.deploy?.replicas !== undefined) { const deploy = toPortNumber(service.deploy.replicas); if (deploy !== undefined) { assertNumericString("deploy.replicas", String(service.deploy.replicas)); return deploy; } assertNumericString("deploy.replicas", String(service.deploy.replicas)); } return 1; } export function resolveReplicaSpec(service: Service): ReplicaSpec { const scaleRange = parseReplicaRange(service.scale); if (scaleRange) return normalizeReplicaRange(scaleRange); if (service.scale !== undefined) { return { kind: "fixed", value: toReplicaCount(service) }; } const deployRange = parseReplicaRange(service.deploy?.replicas); if (deployRange) return normalizeReplicaRange(deployRange); return { kind: "fixed", value: toReplicaCount(service) }; } function normalizeReplicaRange(range: ReplicaRange): ReplicaSpec { if (range.min === range.max) { return { kind: "fixed", value: range.min }; } return range; } function replicasCanExceedOne(replicaSpec: ReplicaSpec): boolean { if (replicaSpec.kind === "range") return replicaSpec.max > 1; return replicaSpec.value > 1; } function toDeploymentImage( name: string, service: Service, buildImages: Record, ): string | undefined { if (service.build) { const image = buildImages[name]; if (!image) { throw new Error(`Missing resolved build image for service ${name}`); } return image; } return service.image; } function toProtocol(value: string | undefined): "TCP" | "UDP" { return value?.toUpperCase() === "UDP" ? "UDP" : "TCP"; } function toHostname(value: string | undefined): string | undefined { if (!value) return; return /[a-z]/i.test(value) ? value : undefined; } function toBoolean(value: boolean | string | undefined): boolean | undefined { if (typeof value === "boolean") return value; if (!value) return; switch (value.toLowerCase()) { case "1": case "true": case "yes": case "on": case "ro": return true; case "0": case "false": case "no": case "off": case "rw": return false; } } function toKubeName(value: string): string { return value .toLowerCase() .replace(/[^a-z0-9-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 63); } function normalizeStorageSize(value: unknown): string | undefined { if (value === undefined || value === null) return; const size = String(value).trim(); if (!size) return; return size; } function normalizeDiskTags(value: unknown): string[] | undefined { if (value === undefined || value === null) return; const tags = (Array.isArray(value) ? value : [value]) .flatMap((entry) => String(entry).split(",")) .map((entry) => entry.trim()) .filter(Boolean); return tags.length > 0 ? [...new Set(tags)] : undefined; } function normalizeReplicaCount(value: unknown): number | undefined { if (value === undefined || value === null || value === "") return; const count = Number(value); return Number.isInteger(count) && count >= 1 && count <= 20 ? count : undefined; } function parseStorageSpec(value: string): { requestedStorage?: string; diskTag?: string[]; replicaCount?: number; dataLocality?: "none"; } { const [sizePart, placementPart] = value .split(/\s+on\s+/i, 2) .map((part) => part?.trim()); if (!placementPart) { return { requestedStorage: normalizeStorageSize(sizePart), }; } const placementMatch = /^(?:(?\d+)\s+)?(?.+)$/.exec( placementPart, ); return { requestedStorage: normalizeStorageSize(sizePart), diskTag: normalizeDiskTags(placementMatch?.groups?.tags), replicaCount: normalizeReplicaCount(placementMatch?.groups?.replicas), dataLocality: "none", }; } function parseNamedVolumeSize(source: string): | { source: string; requestedStorage?: string; diskTag?: string[]; replicaCount?: number; dataLocality?: "none"; } | undefined { const match = /^(?[^()]+)\((?[^()]+)\)$/.exec(source.trim()); if (!match?.groups) return; const name = match.groups.name?.trim(); const { requestedStorage, diskTag, replicaCount, dataLocality } = parseStorageSpec(match.groups.size ?? ""); if (!name) return; return { source: name, requestedStorage, diskTag, replicaCount, dataLocality, }; } function resolveVolumeSource(source: string | undefined): { source: string | undefined; requestedStorage?: string; diskTag?: string[]; replicaCount?: number; dataLocality?: "none"; } { if (!source || isBindSource(source)) return { source }; const sized = parseNamedVolumeSize(source); if (!sized) return { source }; if (!sized.source) { throw new Error(`Invalid volume source ${source}. Expected name(size).`); } return sized; } function getVolumeExtensionSize( volume: { [key: string]: unknown } | undefined, ): string | undefined { return normalizeStorageSize(volume?.["x-size"]); } function getVolumeExtensionDiskTag( volume: { [key: string]: unknown } | undefined, ): string[] | undefined { return normalizeDiskTags(volume?.["x-diskTag"]); } function getVolumeExtensionReplicaCount( volume: { [key: string]: unknown } | undefined, ): number | undefined { return normalizeReplicaCount(volume?.["x-replicaCount"]); } function getVolumeExtensionDataLocality( volume: { [key: string]: unknown } | undefined, ): "none" | undefined { return volume?.["x-dataLocality"] === "none" ? "none" : undefined; } function toBindVolumeName(source: string): string { return toKubeName(`bind-${Bun.hash(source).toString(36)}`); } function isBindSource(source: string): boolean { return ( source.startsWith(".") || source.startsWith("/") || source.startsWith("~") || source.includes("/") ); } function isConfigFileSource( source: string, artifacts: ArtifactProvider, ): boolean { return artifacts.isFile(source, { expandHome: true }); } function parseStringMount( entry: string, index: number, artifacts: ArtifactProvider, ): NormalizedMount | undefined { if (isPostgresVolumeEntry(entry) || isS3VolumeEntry(entry)) return; const parts = entry.split(":"); if (parts.length === 1) { return { name: `volume-${index}`, kind: "volume", target: entry, }; } const maybeMode = parts.at(-1); const hasMode = maybeMode === "ro" || maybeMode === "rw"; const target = parts.at(hasMode ? -2 : -1); const rawSource = parts.slice(0, hasMode ? -2 : -1).join(":"); if (!target) return; const { source, requestedStorage, diskTag, replicaCount, dataLocality } = resolveVolumeSource(rawSource || undefined); return { name: source && isBindSource(source) ? toBindVolumeName(source) : `volume-${index}`, kind: source && isBindSource(source) ? isConfigFileSource(source, artifacts) ? "config-file" : "bind" : "volume", source: source || undefined, target, configKey: source ? basename(source) : undefined, readOnly: hasMode ? maybeMode === "ro" : undefined, requestedStorage, diskTag, replicaCount, dataLocality, }; } function toMount( entry: ServiceVolume, index: number, artifacts: ArtifactProvider, ): NormalizedMount | undefined { if (typeof entry === "string") return parseStringMount(entry, index, artifacts); if (!entry.target) return; if ( entry.type !== "bind" && entry.type !== "volume" && entry.type !== "tmpfs" ) { return; } const { source, requestedStorage, diskTag, replicaCount, dataLocality } = resolveVolumeSource(entry.source); return { name: source ? entry.type === "bind" ? toBindVolumeName(source) : `${entry.type}-${toKubeName(source) || index}` : `${entry.type}-${index}`, kind: entry.type === "bind" && source && isConfigFileSource(source, artifacts) ? "config-file" : entry.type, source, target: entry.target, readOnly: toBoolean(entry.read_only), configKey: source ? basename(source) : undefined, subPath: entry.volume?.subpath, sizeLimit: entry.type === "tmpfs" && entry.tmpfs?.size !== undefined ? String(entry.tmpfs.size) : undefined, requestedStorage: requestedStorage ?? getVolumeExtensionSize(entry.volume), diskTag: diskTag ?? getVolumeExtensionDiskTag(entry.volume), replicaCount: replicaCount ?? getVolumeExtensionReplicaCount(entry.volume), dataLocality: dataLocality ?? getVolumeExtensionDataLocality(entry.volume), }; } function getTopLevelVolumeSize( volumes: ComposeVolumes, source: string | undefined, ): string | undefined { if (!source || !volumes) return; const volume = volumes[source] as { [key: string]: unknown } | undefined; return getVolumeExtensionSize(volume); } function getTopLevelVolumeDiskTag( volumes: ComposeVolumes, source: string | undefined, ): string[] | undefined { if (!source || !volumes) return; const volume = volumes[source] as { [key: string]: unknown } | undefined; return getVolumeExtensionDiskTag(volume); } function getTopLevelVolumeReplicaCount( volumes: ComposeVolumes, source: string | undefined, ): number | undefined { if (!source || !volumes) return; const volume = volumes[source] as { [key: string]: unknown } | undefined; return getVolumeExtensionReplicaCount(volume); } function getTopLevelVolumeDataLocality( volumes: ComposeVolumes, source: string | undefined, ): "none" | undefined { if (!source || !volumes) return; const volume = volumes[source] as { [key: string]: unknown } | undefined; return getVolumeExtensionDataLocality(volume); } function toMounts( service: Service, artifacts: ArtifactProvider, volumes: ComposeVolumes = {}, ): NormalizedMount[] { const mounts = service.volumes?.flatMap((entry, index) => { const mount = toMount(entry, index, artifacts); if (!mount) return []; return [ { ...mount, requestedStorage: mount.requestedStorage ?? getTopLevelVolumeSize(volumes, mount.source) ?? (mount.kind === "volume" && mount.source ? DefaultNamedVolumeStorage.requestedStorage : undefined), diskTag: mount.diskTag ?? getTopLevelVolumeDiskTag(volumes, mount.source) ?? (mount.kind === "volume" && mount.source ? DefaultNamedVolumeStorage.diskTag : undefined), replicaCount: mount.replicaCount ?? getTopLevelVolumeReplicaCount(volumes, mount.source) ?? (mount.kind === "volume" && mount.source ? DefaultNamedVolumeStorage.replicaCount : undefined), dataLocality: mount.dataLocality ?? getTopLevelVolumeDataLocality(volumes, mount.source) ?? (mount.kind === "volume" && mount.source ? DefaultNamedVolumeStorage.dataLocality : undefined), }, ]; }) ?? []; const tmpfs = (Array.isArray(service.tmpfs) ? service.tmpfs : [service.tmpfs]) .filter((entry): entry is string => Boolean(entry)) .map((target, index) => ({ name: `tmpfs-${mounts.length + index}`, kind: "tmpfs", target, })); return [...mounts, ...tmpfs]; } function toVolumeMounts( mounts: NormalizedMount[], ): V1VolumeMount[] | undefined { if (mounts.length === 0) return; return mounts.map((mount) => ({ name: mount.name, mountPath: mount.target, readOnly: mount.readOnly, subPath: mount.kind === "config-file" ? mount.configKey : mount.subPath, })); } function toVolumes( project: string, mounts: NormalizedMount[], ): V1Volume[] | undefined { if (mounts.length === 0) return; return mounts.map((mount) => { if (mount.kind === "config-file") { return { name: mount.name, configMap: { name: mount.name, }, }; } if (mount.kind === "bind") { if (!mount.source) { return { name: mount.name, emptyDir: {}, }; } return { name: mount.name, persistentVolumeClaim: { claimName: mount.name, }, }; } if (mount.kind === "tmpfs") { return { name: mount.name, emptyDir: { medium: "Memory", sizeLimit: mount.sizeLimit, }, }; } if (!mount.source) { return { name: mount.name, emptyDir: {}, }; } return { name: mount.name, persistentVolumeClaim: { claimName: toKubeName(`${project}-${mount.source}`), }, }; }); } function normalizeProtectedPaths(value: string): string[] { return [ ...new Set( value .split(",") .map((segment) => segment.trim()) .filter(Boolean) .map((segment) => (segment.startsWith("/") ? segment : `/${segment}`)), ), ]; } function parsePortRoute(value: string): { portSpec: string; routingKind?: "ingress" | "ingressroute"; paths?: string[]; } { const protectedMatch = /^(?.+):protected(?:\((?[^)]*)\))?$/.exec(value); if (!protectedMatch?.groups) return { portSpec: value }; const portSpec = protectedMatch.groups.portSpec?.trim(); if (!portSpec) { throw new Error(`Invalid protected port syntax ${value}.`); } const rawPaths = protectedMatch.groups.paths?.trim(); if (rawPaths === undefined) { return { portSpec, routingKind: "ingressroute" }; } const paths = normalizeProtectedPaths(rawPaths); if (paths.length === 0) { throw new Error( `Invalid protected port syntax ${value}. Expected one or more paths.`, ); } return { portSpec, routingKind: "ingressroute", paths }; } function toPorts(service: Service): NormalizedPort[] { const ports = service.ports?.flatMap((entry, index) => { if (typeof entry === "number") { return [ { name: `port-${index}`, containerPort: entry, servicePort: entry, protocol: "TCP", }, ]; } if (typeof entry === "string") { const protocolMatch = /\/(tcp|udp)$/i.exec(entry); const rawPortSpec = protocolMatch ? entry.slice(0, -protocolMatch[0].length) : entry; const protocolSpec = protocolMatch?.[1]; if (!rawPortSpec) return []; const { portSpec, routingKind, paths } = parsePortRoute(rawPortSpec); const segments = portSpec.split(":"); const target = toPortNumber(segments.at(-1)); if (!target) return []; const published = toPortNumber(segments.at(-2)); const host = published !== undefined ? toHostname(segments.at(-3)) : toHostname(segments.at(-2)); return [ { name: `port-${index}`, containerPort: target, servicePort: published ?? target, hostPort: published, host, protocol: toProtocol(protocolSpec), routingKind: host ? (routingKind ?? "ingress") : undefined, paths, }, ]; } const target = toPortNumber(entry.target); if (!target) return []; const published = toPortNumber(entry.published); return [ { name: entry.name ?? `port-${index}`, containerPort: target, servicePort: published ?? target, hostPort: published, host: toHostname(entry.host_ip), protocol: toProtocol(entry.protocol), routingKind: toHostname(entry.host_ip) ? "ingress" : undefined, }, ]; }) ?? []; const exposedPorts = service.expose?.flatMap((entry, index) => { const [portSpec, protocolSpec] = String(entry).split("/"); const target = toPortNumber(portSpec); if (!target) return []; return [ { name: `expose-${index}`, containerPort: target, servicePort: target, protocol: toProtocol(protocolSpec), }, ]; }) ?? []; return [...ports, ...exposedPorts]; } function toContainerPorts( ports: NormalizedPort[], ): V1ContainerPort[] | undefined { if (ports.length === 0) return; const deduped = new Map(); for (const port of ports) { deduped.set(`${port.containerPort}:${port.protocol}`, { name: port.name, containerPort: port.containerPort, hostPort: port.hostPort, protocol: port.protocol, }); } return [...deduped.values()]; } function toServicePorts(ports: NormalizedPort[]): V1ServicePort[] | undefined { if (ports.length === 0) return; const deduped = new Map(); for (const port of ports) { deduped.set(`${port.servicePort}:${port.protocol}`, { name: port.name, port: port.servicePort, targetPort: port.containerPort, protocol: port.protocol, }); } return [...deduped.values()]; } function toIngressRules(name: string, ports: NormalizedPort[]) { const seenHosts = new Set(); return ports.flatMap((port) => { if ( !port.host || port.routingKind === "ingressroute" || seenHosts.has(port.host) ) { return []; } seenHosts.add(port.host); return [ { host: port.host, http: { paths: [ { path: "/", pathType: "Prefix" as const, backend: { service: { name, port: { number: port.servicePort }, }, }, }, ], }, }, ]; }); } function toIngressRoute( project: string, name: string, ports: NormalizedPort[], ): KubernetesObject | undefined { const routes = ports.flatMap((port) => { if (!port.host || port.routingKind !== "ingressroute") return []; const matches = port.paths && port.paths.length > 0 ? port.paths.map( (path) => `Host(\`${port.host}\`) && PathPrefix(\`${path}\`)`, ) : [`Host(\`${port.host}\`)`]; return matches.map((match) => ({ kind: "Rule", match, middlewares: [ { name: "cf-auth", namespace: "routing", }, ], services: [ { name, port: port.servicePort, }, ], })); }); if (routes.length === 0) return; return { apiVersion: "traefik.io/v1alpha1", kind: "IngressRoute", metadata: { name, namespace: project, labels: LABELS, }, spec: { routes, }, } as KubernetesObject; } function toEnvFilePaths( envFile: Service["env_file"], ): { path: string; required: boolean }[] { if (!envFile) return []; const entries = Array.isArray(envFile) ? envFile : [envFile]; return entries.map((entry) => typeof entry === "string" ? { path: entry, required: true } : { path: entry.path, required: entry.required !== false && entry.required !== "false", }, ); } function parseEnvFile(text: string): Record { const result: Record = {}; for (const rawLine of text.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith("#")) continue; const separator = line.indexOf("="); if (separator === -1) continue; const key = line.slice(0, separator).trim(); const value = line.slice(separator + 1); if (!key) continue; result[key] = value; } return result; } async function readEnvFiles( envFile: Service["env_file"], artifacts: ArtifactProvider, ): Promise> { const result: Record = {}; for (const entry of toEnvFilePaths(envFile)) { try { const text = artifacts.readText(entry.path); Object.assign(result, parseEnvFile(text)); } catch (error) { if ( !entry.required && error && typeof error === "object" && "code" in error && error.code === "ENOENT" ) { continue; } throw error; } } return result; } export function serviceToDeployment( project: string, name: string, compose: ComposeSpecification, service: Service, cwd = process.cwd(), extraEnv: Record = {}, volumes: ComposeVolumes = {}, buildImages: Record = {}, artifacts: ArtifactProvider = new LocalArtifactProvider({ workspace: cwd }), options: { replicaSpec?: ReplicaSpec } = {}, ): V1Deployment { const mounts = toMounts(service, artifacts, volumes); const ports = toPorts(service); const hasEnvSecret = Boolean(service.env_file) || Object.keys(extraEnv).length > 0; const replicaSpec = options.replicaSpec ?? resolveReplicaSpec(service); const replicas = replicaSpec.kind === "range" ? replicaSpec.min : replicaSpec.value; const containers = [ deepMerge( { name, imagePullPolicy: "Always", env: toEnvVars(service.environment), image: toDeploymentImage(name, service, buildImages), command: service.command instanceof Array ? service.command : service.command ? service.command.split(" ") : undefined, envFrom: hasEnvSecret ? [{ secretRef: { name: `${name}-env` } }] : undefined, ports: toContainerPorts(ports), volumeMounts: toVolumeMounts(mounts), } satisfies V1Container, service["x-container"] ?? {}, ), ]; const podSpec: V1PodSpec = { restartPolicy: "Always", ...getComposeArchPlacement(compose), volumes: toVolumes(project, mounts), containers, }; if (replicasCanExceedOne(replicaSpec)) { podSpec.topologySpreadConstraints = [ { maxSkew: 1, topologyKey: "kubernetes.io/hostname", whenUnsatisfiable: "ScheduleAnyway", labelSelector: { matchLabels: { app: name } }, }, ]; } return deepMerge( { apiVersion: "apps/v1", kind: "Deployment", metadata: { name, namespace: project, labels: LABELS, }, spec: { replicas, strategy: { rollingUpdate: { maxSurge: "25%", maxUnavailable: "25%", }, }, selector: { matchLabels: { app: name }, }, template: { metadata: { labels: { app: name, }, }, spec: podSpec, }, }, } satisfies V1Deployment, service["x-deployment"] ?? {}, ); } export function serviceToSvc( project: string, name: string, service: Service, ): V1Service | undefined { const servicePorts = toServicePorts(toPorts(service)); if (!servicePorts) return; return { apiVersion: "v1", kind: "Service", metadata: { name, namespace: project, labels: LABELS, }, spec: { type: "ClusterIP", selector: { app: name }, ports: servicePorts, }, }; } export function serviceToIngress( project: string, name: string, service: Service, ): V1Ingress | undefined { const rules = toIngressRules(name, toPorts(service)); if (rules.length === 0) return; return { apiVersion: "networking.k8s.io/v1", kind: "Ingress", metadata: { name, namespace: project, labels: LABELS, }, spec: { rules }, }; } export function volumesToPvc( project: string, service: Service, cwd = process.cwd(), volumes: ComposeVolumes = {}, artifacts: ArtifactProvider = new LocalArtifactProvider({ workspace: cwd }), ): V1PersistentVolumeClaim[] { const claims = new Map(); for (const mount of toMounts(service, artifacts, volumes)) { const claimName = mount.kind === "bind" ? mount.source ? mount.name : undefined : mount.kind === "volume" && mount.source ? toKubeName(`${project}-${mount.source}`) : undefined; if (!claimName || claims.has(claimName)) continue; const policy = getLonghornStoragePolicy(mount); claims.set(claimName, { apiVersion: "v1", kind: "PersistentVolumeClaim", metadata: { name: claimName, namespace: project, labels: LABELS, }, spec: { accessModes: ["ReadWriteMany"], storageClassName: policy ? toLonghornStorageClassName(policy) : undefined, resources: { requests: { storage: mount.requestedStorage ?? "1Gi", }, }, }, }); } return [...claims.values()]; } export function volumesToStorageClasses( service: Service, cwd = process.cwd(), volumes: ComposeVolumes = {}, artifacts: ArtifactProvider = new LocalArtifactProvider({ workspace: cwd }), ): LonghornStorageClass[] { const classes = new Map(); for (const mount of toMounts(service, artifacts, volumes)) { const policy = getLonghornStoragePolicy(mount); if (!policy) continue; const name = toLonghornStorageClassName(policy); classes.set(name, { apiVersion: "storage.k8s.io/v1", kind: "StorageClass", metadata: { name, labels: LABELS, }, provisioner: "driver.longhorn.io", allowVolumeExpansion: true, reclaimPolicy: "Delete", volumeBindingMode: "Immediate", parameters: { ...(policy.diskTag ? { diskSelector: policy.diskTag.join(",") } : {}), ...(policy.replicaCount !== undefined ? { numberOfReplicas: String(policy.replicaCount) } : {}), ...(policy.dataLocality ? { dataLocality: "disabled" } : {}), }, }); } return [...classes.values()]; } export function volumesToConfigMaps( project: string, service: Service, cwd = process.cwd(), volumes: ComposeVolumes = {}, artifacts: ArtifactProvider = new LocalArtifactProvider({ workspace: cwd }), ): V1ConfigMap[] { const configMaps = new Map(); for (const mount of toMounts(service, artifacts, volumes)) { if (mount.kind !== "config-file" || !mount.source || !mount.configKey) continue; configMaps.set(mount.name, { apiVersion: "v1", kind: "ConfigMap", metadata: { name: mount.name, namespace: project, labels: LABELS, }, data: { [mount.configKey]: artifacts.readText(mount.source, { expandHome: true, }), }, }); } return [...configMaps.values()]; } export async function envFromToSecrets( project: string, name: string, service: Service, cwd = process.cwd(), extraEnv: Record = {}, artifacts: ArtifactProvider = new LocalArtifactProvider({ workspace: cwd }), ): Promise { const stringData = { ...(await readEnvFiles(service.env_file, artifacts)), ...extraEnv, }; if (Object.keys(stringData).length === 0) return []; return [ { apiVersion: "v1", kind: "Secret", metadata: { name: `${name}-env`, namespace: project, labels: LABELS, }, type: "Opaque", stringData, }, ]; } function checksumSecret(secret: V1Secret): string { const entries = Object.entries(secret.stringData ?? {}).sort( ([left], [right]) => left.localeCompare(right), ); return createHash("sha256").update(JSON.stringify(entries)).digest("hex"); } export function composeToNamespace(project: string): V1Namespace { return { apiVersion: "v1", kind: "Namespace", metadata: { name: project, labels: LABELS, }, }; } export type KubernetesResource = | V1Namespace | V1PersistentVolumeClaim | V1Secret | V1Service | V1Deployment | V1Ingress | V1ConfigMap | KubernetesObject | { apiVersion: string; kind: "HorizontalPodAutoscaler"; metadata: { name: string; namespace: string; labels: Record; }; spec: { scaleTargetRef: { apiVersion: string; kind: string; name: string; }; minReplicas: number; maxReplicas: number; metrics: [ { type: "Resource"; resource: { name: string; target: { type: "Utilization"; averageUtilization: number; }; }; }, ]; }; }; function injectCpuRequestIfMissing( deployment: V1Deployment, service: Service, ): void { const container = deployment.spec?.template.spec?.containers[0]; if (!container) return; const xContainerCpu = (service["x-container"] as { resources?: { requests?: { cpu?: unknown } } }) ?.resources?.requests?.cpu; if (xContainerCpu !== undefined) return; container.resources ??= {}; container.resources.requests ??= {}; if (container.resources.requests.cpu === undefined) { container.resources.requests.cpu = "100m"; } } function serviceToHpa( project: string, name: string, range: ReplicaRange, ): KubernetesResource { return { apiVersion: "autoscaling/v2", kind: "HorizontalPodAutoscaler", metadata: { name, namespace: project, labels: LABELS, }, spec: { scaleTargetRef: { apiVersion: "apps/v1", kind: "Deployment", name, }, minReplicas: range.min, maxReplicas: range.max, metrics: [ { type: "Resource", resource: { name: "cpu", target: { type: "Utilization", averageUtilization: 80, }, }, }, ], }, }; } function getResourceKey(resource: KubernetesObject): string { return `${resource.kind}:${resource.metadata?.namespace ?? ""}:${resource.metadata?.name ?? ""}`; } export async function composeToKubernetes( project: string, compose: ComposeSpecification, cwd = process.cwd(), serviceEnv: Record> = {}, buildImages: Record = {}, artifacts: ArtifactProvider = new LocalArtifactProvider({ workspace: cwd }), ): Promise { const resources = new Map(); const namespace = composeToNamespace(project); resources.set(getResourceKey(namespace), namespace); for (const [name, service] of Object.entries(compose.services ?? {})) { for (const storageClass of volumesToStorageClasses( service, cwd, compose.volumes, artifacts, )) { resources.set(getResourceKey(storageClass), storageClass); } for (const pvc of volumesToPvc( project, service, cwd, compose.volumes, artifacts, )) { resources.set(getResourceKey(pvc), pvc); } for (const configMap of volumesToConfigMaps( project, service, cwd, compose.volumes, artifacts, )) { resources.set(getResourceKey(configMap), configMap); } const envSecrets = await envFromToSecrets( project, name, service, cwd, serviceEnv[name] ?? {}, artifacts, ); for (const secret of envSecrets) { resources.set(getResourceKey(secret), secret); } const svc = serviceToSvc(project, name, service); if (svc) resources.set(getResourceKey(svc), svc); const replicaSpec = resolveReplicaSpec(service); const deployment = serviceToDeployment( project, name, compose, service, cwd, serviceEnv[name] ?? {}, compose.volumes, buildImages, artifacts, { replicaSpec }, ); const envSecret = envSecrets[0]; if (envSecret) { deployment.spec!.template.metadata!.annotations = { ...deployment.spec?.template.metadata?.annotations, "kuber.astrxl.dev/env-checksum": checksumSecret(envSecret), }; } resources.set(getResourceKey(deployment), deployment); if (replicaSpec.kind === "range") { injectCpuRequestIfMissing(deployment, service); const hpa = serviceToHpa(project, name, replicaSpec); resources.set(getResourceKey(hpa), hpa); } const ingress = serviceToIngress(project, name, service); if (ingress) resources.set(getResourceKey(ingress), ingress); const ingressRoute = toIngressRoute(project, name, toPorts(service)); if (ingressRoute) resources.set(getResourceKey(ingressRoute), ingressRoute); } return [...resources.values()]; }