import type { KubernetesObject, V1Container, V1ContainerPort, V1ConfigMap, V1Deployment, V1Ingress, V1Namespace, V1PersistentVolumeClaim, V1Secret, V1Service, V1ServicePort, V1Volume, V1VolumeMount, } from "@kubernetes/client-node"; import { createHash } from "node:crypto"; 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 { LABELS } from "../const"; import { getComposeArchPlacement } from "./arch"; import { DEFAULT_REGISTRY } from "./config"; 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[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, registry: string, ): string | undefined { if (service.build) return `${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 = /^(?:(?\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 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((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"], cwd: string, ): Promise> { const result: Record = {}; 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 = {}, volumes: ComposeVolumes = {}, registry = DEFAULT_REGISTRY, ): 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, registry), 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(); 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(); 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 = {}, ): Promise { 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, }, ]; } 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; 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> = {}, registry = DEFAULT_REGISTRY, ): 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 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); } const envSecrets = await envFromToSecrets( project, name, service, cwd, serviceEnv[name] ?? {}, ); for (const secret of envSecrets) { 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, registry, ); 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); 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()]; }