feat: add amd64 node support

This commit is contained in:
2026-08-28 21:19:50 +07:00 Unverified
parent 23b4ccaa80
commit a1c3298f02
10 changed files with 856 additions and 177 deletions
+390 -40
View File
@@ -19,6 +19,7 @@ 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 { getServicePostgresClaim, isPostgresVolumeEntry } from "./database";
import { toEnvVars } from "./format";
import { deepMerge } from "./shared";
@@ -32,6 +33,10 @@ type NormalizedMount = {
configKey?: string;
subPath?: string;
sizeLimit?: string;
requestedStorage?: string;
diskTag?: string[];
replicaCount?: number;
dataLocality?: "none";
};
type NormalizedPort = {
@@ -41,9 +46,19 @@ type NormalizedPort = {
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;
@@ -105,6 +120,126 @@ function toKubeName(value: string): string {
.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)}`);
}
@@ -149,10 +284,13 @@ function parseStringMount(
const maybeMode = parts.at(-1);
const hasMode = maybeMode === "ro" || maybeMode === "rw";
const target = parts.at(hasMode ? -2 : -1);
const source = parts.slice(0, hasMode ? -2 : -1).join(":");
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)
@@ -168,6 +306,10 @@ function parseStringMount(
target,
configKey: source ? basename(source) : undefined,
readOnly: hasMode ? maybeMode === "ro" : undefined,
requestedStorage,
diskTag,
replicaCount,
dataLocality,
};
}
@@ -187,35 +329,114 @@ function toMount(
return;
}
const { source, requestedStorage, diskTag, replicaCount, dataLocality } =
resolveVolumeSource(entry.source);
return {
name: entry.source
name: source
? entry.type === "bind"
? toBindVolumeName(entry.source)
: `${entry.type}-${toKubeName(entry.source) || index}`
? toBindVolumeName(source)
: `${entry.type}-${toKubeName(source) || index}`
: `${entry.type}-${index}`,
kind:
entry.type === "bind" &&
entry.source &&
isConfigFileSource(entry.source, cwd)
entry.type === "bind" && source && isConfigFileSource(source, cwd)
? "config-file"
: entry.type,
source: entry.source,
source,
target: entry.target,
readOnly: toBoolean(entry.read_only),
configKey: entry.source ? basename(entry.source) : undefined,
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 toMounts(service: Service, cwd = process.cwd()): NormalizedMount[] {
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);
return mount ? [mount] : [];
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])
@@ -300,6 +521,47 @@ function toVolumes(
});
}
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) => {
@@ -315,8 +577,10 @@ function toPorts(service: Service): NormalizedPort[] {
}
if (typeof entry === "string") {
const [portSpec, protocolSpec] = entry.split("/");
if (!portSpec) return [];
const [rawPortSpec, protocolSpec] = entry.split("/");
if (!rawPortSpec) return [];
const { portSpec, routingKind, paths } = parsePortRoute(rawPortSpec);
const segments = portSpec.split(":");
const target = toPortNumber(segments.at(-1));
@@ -336,6 +600,8 @@ function toPorts(service: Service): NormalizedPort[] {
hostPort: published,
host,
protocol: toProtocol(protocolSpec),
routingKind: host ? (routingKind ?? "ingress") : undefined,
paths,
},
];
}
@@ -353,6 +619,7 @@ function toPorts(service: Service): NormalizedPort[] {
hostPort: published,
host: toHostname(entry.host_ip),
protocol: toProtocol(entry.protocol),
routingKind: toHostname(entry.host_ip) ? "ingress" : undefined,
},
];
}) ?? [];
@@ -373,12 +640,7 @@ function toPorts(service: Service): NormalizedPort[] {
];
}) ?? [];
const deduped = new Map<string, NormalizedPort>();
for (const port of [...ports, ...exposedPorts]) {
deduped.set(`${port.containerPort}:${port.protocol}`, port);
}
return [...deduped.values()];
return [...ports, ...exposedPorts];
}
function toContainerPorts(
@@ -386,30 +648,49 @@ function toContainerPorts(
): V1ContainerPort[] | undefined {
if (ports.length === 0) return;
return ports.map((port) => ({
name: port.name,
containerPort: port.containerPort,
hostPort: port.hostPort,
protocol: port.protocol,
}));
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;
return ports.map((port) => ({
name: port.name,
port: port.servicePort,
targetPort: port.containerPort,
protocol: port.protocol,
}));
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 || seenHosts.has(port.host)) return [];
if (
!port.host ||
port.routingKind === "ingressroute" ||
seenHosts.has(port.host)
) {
return [];
}
seenHosts.add(port.host);
return [
@@ -423,7 +704,7 @@ function toIngressRules(name: string, ports: NormalizedPort[]) {
backend: {
service: {
name,
port: { name: port.name },
port: { number: port.servicePort },
},
},
},
@@ -434,6 +715,55 @@ function toIngressRules(name: string, ports: NormalizedPort[]) {
});
}
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 }[] {
@@ -502,11 +832,13 @@ async function readEnvFiles(
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);
const mounts = toMounts(service, cwd, volumes);
const ports = toPorts(service);
const hasEnvSecret =
Boolean(service.env_file) ||
@@ -541,6 +873,7 @@ export function serviceToDeployment(
},
spec: {
restartPolicy: "Always",
...getComposeArchPlacement(compose),
volumes: toVolumes(project, mounts),
containers: [
deepMerge(
@@ -620,10 +953,11 @@ 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)) {
for (const mount of toMounts(service, cwd, volumes)) {
const claimName =
mount.kind === "bind"
? mount.source
@@ -647,11 +981,16 @@ export function volumesToPvc(
accessModes: ["ReadWriteMany"],
resources: {
requests: {
storage: "1Gi",
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()];
@@ -661,10 +1000,11 @@ 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)) {
for (const mount of toMounts(service, cwd, volumes)) {
if (mount.kind !== "config-file" || !mount.source || !mount.configKey)
continue;
@@ -751,11 +1091,16 @@ export async function composeToKubernetes(
resources.set(getResourceKey(namespace), namespace);
for (const [name, service] of Object.entries(compose.services ?? {})) {
for (const pvc of volumesToPvc(project, service, cwd)) {
for (const pvc of volumesToPvc(project, service, cwd, compose.volumes)) {
resources.set(getResourceKey(pvc), pvc);
}
for (const configMap of volumesToConfigMaps(project, service, cwd)) {
for (const configMap of volumesToConfigMaps(
project,
service,
cwd,
compose.volumes,
)) {
resources.set(getResourceKey(configMap), configMap);
}
@@ -775,14 +1120,19 @@ export async function composeToKubernetes(
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()];