Files
kuber/lib/convert.ts
T

1464 lines
37 KiB
TypeScript

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<Service["volumes"]>[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<string, string>;
};
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, string>,
): 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 = /^(?:(?<replicas>\d+)\s+)?(?<tags>.+)$/.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 = /^(?<name>[^()]+)\((?<size>[^()]+)\)$/.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<NormalizedMount>((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 =
/^(?<portSpec>.+):protected(?:\((?<paths>[^)]*)\))?$/.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<NormalizedPort>((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<NormalizedPort>((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<string, V1ContainerPort>();
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<string, V1ServicePort>();
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<string>();
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<string, string> {
const result: Record<string, string> = {};
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<Record<string, string>> {
const result: Record<string, string> = {};
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<string, string> = {},
volumes: ComposeVolumes = {},
buildImages: Record<string, string> = {},
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<string, V1PersistentVolumeClaim>();
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<string, LonghornStorageClass>();
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<string, V1ConfigMap>();
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<string, string> = {},
artifacts: ArtifactProvider = new LocalArtifactProvider({ workspace: cwd }),
): Promise<V1Secret[]> {
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<string, string>;
};
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<string, Record<string, string>> = {},
buildImages: Record<string, string> = {},
artifacts: ArtifactProvider = new LocalArtifactProvider({ workspace: cwd }),
): Promise<KubernetesResource[]> {
const resources = new Map<string, KubernetesResource>();
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()];
}