1139 lines
28 KiB
TypeScript
1139 lines
28 KiB
TypeScript
import type {
|
|
KubernetesObject,
|
|
V1Container,
|
|
V1ContainerPort,
|
|
V1ConfigMap,
|
|
V1Deployment,
|
|
V1Ingress,
|
|
V1Namespace,
|
|
V1PersistentVolumeClaim,
|
|
V1Secret,
|
|
V1Service,
|
|
V1ServicePort,
|
|
V1Volume,
|
|
V1VolumeMount,
|
|
} from "@kubernetes/client-node";
|
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
import { readFile } from "node:fs/promises";
|
|
import { basename, resolve } from "node:path";
|
|
import type { ComposeSpecification } from "../schema/docker.d";
|
|
import type { Service } from "../schema/docker.d";
|
|
import { IMAGE_REGISTRY, LABELS } from "../const";
|
|
import { getComposeArchPlacement } from "./arch";
|
|
import { isPostgresVolumeEntry } from "./database";
|
|
import { toEnvVars } from "./format";
|
|
import { deepMerge } from "./shared";
|
|
import { isS3VolumeEntry } from "./storage";
|
|
|
|
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"];
|
|
|
|
const DefaultNamedVolumeStorage = {
|
|
requestedStorage: "1Gi",
|
|
diskTag: ["fast"],
|
|
replicaCount: 2,
|
|
dataLocality: "none" as const,
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
function toReplicaCount(service: Service): number {
|
|
return (
|
|
toPortNumber(service.scale) ?? toPortNumber(service.deploy?.replicas) ?? 1
|
|
);
|
|
}
|
|
|
|
function toDeploymentImage(
|
|
project: string,
|
|
name: string,
|
|
service: Service,
|
|
): string | undefined {
|
|
if (service.build) return `${IMAGE_REGISTRY}/kuber/${project}-${name}:latest`;
|
|
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 : 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 resolveSourcePath(source: string, cwd: string): string {
|
|
return source.startsWith("~")
|
|
? resolve(process.env.HOME ?? "", source.slice(1))
|
|
: resolve(cwd, source);
|
|
}
|
|
|
|
function isConfigFileSource(source: string, cwd: string): boolean {
|
|
const path = resolveSourcePath(source, cwd);
|
|
return existsSync(path) && statSync(path).isFile();
|
|
}
|
|
|
|
function parseStringMount(
|
|
entry: string,
|
|
index: number,
|
|
cwd: string,
|
|
): 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, cwd)
|
|
? "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,
|
|
cwd: string,
|
|
): NormalizedMount | undefined {
|
|
if (typeof entry === "string") return parseStringMount(entry, index, cwd);
|
|
|
|
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, cwd)
|
|
? "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,
|
|
cwd = process.cwd(),
|
|
volumes: ComposeVolumes = {},
|
|
): NormalizedMount[] {
|
|
const mounts =
|
|
service.volumes?.flatMap((entry, index) => {
|
|
const mount = toMount(entry, index, cwd);
|
|
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 [rawPortSpec, protocolSpec] = entry.split("/");
|
|
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"],
|
|
cwd: string,
|
|
): Promise<Record<string, string>> {
|
|
const result: Record<string, string> = {};
|
|
|
|
for (const entry of toEnvFilePaths(envFile)) {
|
|
const path = resolve(cwd, entry.path);
|
|
|
|
try {
|
|
const text = await readFile(path, "utf8");
|
|
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 = {},
|
|
): V1Deployment {
|
|
const mounts = toMounts(service, cwd, volumes);
|
|
const ports = toPorts(service);
|
|
const hasEnvSecret =
|
|
Boolean(service.env_file) || Object.keys(extraEnv).length > 0;
|
|
|
|
return deepMerge(
|
|
{
|
|
apiVersion: "apps/v1",
|
|
kind: "Deployment",
|
|
metadata: {
|
|
name,
|
|
namespace: project,
|
|
labels: LABELS,
|
|
},
|
|
spec: {
|
|
replicas: toReplicaCount(service),
|
|
strategy: {
|
|
rollingUpdate: {
|
|
maxSurge: "25%",
|
|
maxUnavailable: "25%",
|
|
},
|
|
},
|
|
selector: {
|
|
matchLabels: { app: name },
|
|
},
|
|
template: {
|
|
metadata: {
|
|
labels: {
|
|
app: name,
|
|
},
|
|
},
|
|
spec: {
|
|
restartPolicy: "Always",
|
|
...getComposeArchPlacement(compose),
|
|
volumes: toVolumes(project, mounts),
|
|
containers: [
|
|
deepMerge(
|
|
{
|
|
name,
|
|
imagePullPolicy: "Always",
|
|
env: toEnvVars(service.environment),
|
|
image: toDeploymentImage(project, name, service),
|
|
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"] ?? {},
|
|
),
|
|
],
|
|
},
|
|
},
|
|
},
|
|
} 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 = {},
|
|
): V1PersistentVolumeClaim[] {
|
|
const claims = new Map<string, V1PersistentVolumeClaim>();
|
|
|
|
for (const mount of toMounts(service, cwd, 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;
|
|
|
|
claims.set(claimName, {
|
|
apiVersion: "v1",
|
|
kind: "PersistentVolumeClaim",
|
|
metadata: {
|
|
name: claimName,
|
|
namespace: project,
|
|
labels: LABELS,
|
|
},
|
|
spec: {
|
|
accessModes: ["ReadWriteMany"],
|
|
resources: {
|
|
requests: {
|
|
storage: mount.requestedStorage ?? "1Gi",
|
|
},
|
|
},
|
|
...(mount.diskTag ? { diskTag: mount.diskTag } : {}),
|
|
...(mount.replicaCount !== undefined
|
|
? { replicaCount: mount.replicaCount }
|
|
: {}),
|
|
...(mount.dataLocality ? { dataLocality: mount.dataLocality } : {}),
|
|
},
|
|
} as V1PersistentVolumeClaim);
|
|
}
|
|
|
|
return [...claims.values()];
|
|
}
|
|
|
|
export function volumesToConfigMaps(
|
|
project: string,
|
|
service: Service,
|
|
cwd = process.cwd(),
|
|
volumes: ComposeVolumes = {},
|
|
): V1ConfigMap[] {
|
|
const configMaps = new Map<string, V1ConfigMap>();
|
|
|
|
for (const mount of toMounts(service, cwd, volumes)) {
|
|
if (mount.kind !== "config-file" || !mount.source || !mount.configKey)
|
|
continue;
|
|
|
|
const sourcePath = resolveSourcePath(mount.source, cwd);
|
|
configMaps.set(mount.name, {
|
|
apiVersion: "v1",
|
|
kind: "ConfigMap",
|
|
metadata: {
|
|
name: mount.name,
|
|
namespace: project,
|
|
labels: LABELS,
|
|
},
|
|
data: {
|
|
[mount.configKey]: readFileSync(sourcePath, "utf8"),
|
|
},
|
|
});
|
|
}
|
|
|
|
return [...configMaps.values()];
|
|
}
|
|
|
|
export async function envFromToSecrets(
|
|
project: string,
|
|
name: string,
|
|
service: Service,
|
|
cwd = process.cwd(),
|
|
extraEnv: Record<string, string> = {},
|
|
): Promise<V1Secret[]> {
|
|
const stringData = {
|
|
...(await readEnvFiles(service.env_file, cwd)),
|
|
...extraEnv,
|
|
};
|
|
if (Object.keys(stringData).length === 0) return [];
|
|
|
|
return [
|
|
{
|
|
apiVersion: "v1",
|
|
kind: "Secret",
|
|
metadata: {
|
|
name: `${name}-env`,
|
|
namespace: project,
|
|
labels: LABELS,
|
|
},
|
|
type: "Opaque",
|
|
stringData,
|
|
},
|
|
];
|
|
}
|
|
|
|
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;
|
|
|
|
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>> = {},
|
|
): 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 pvc of volumesToPvc(project, service, cwd, compose.volumes)) {
|
|
resources.set(getResourceKey(pvc), pvc);
|
|
}
|
|
|
|
for (const configMap of volumesToConfigMaps(
|
|
project,
|
|
service,
|
|
cwd,
|
|
compose.volumes,
|
|
)) {
|
|
resources.set(getResourceKey(configMap), configMap);
|
|
}
|
|
|
|
for (const secret of await envFromToSecrets(
|
|
project,
|
|
name,
|
|
service,
|
|
cwd,
|
|
serviceEnv[name] ?? {},
|
|
)) {
|
|
resources.set(getResourceKey(secret), secret);
|
|
}
|
|
|
|
const svc = serviceToSvc(project, name, service);
|
|
if (svc) resources.set(getResourceKey(svc), svc);
|
|
|
|
const deployment = serviceToDeployment(
|
|
project,
|
|
name,
|
|
compose,
|
|
service,
|
|
cwd,
|
|
serviceEnv[name] ?? {},
|
|
compose.volumes,
|
|
);
|
|
resources.set(getResourceKey(deployment), deployment);
|
|
|
|
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()];
|
|
}
|