feat: add support for replica ranges and HorizontalPodAutoscaler integration
This commit is contained in:
@@ -357,7 +357,8 @@ your shell configuration. Fish and PowerShell are also supported through
|
||||
- `exec <deployment> <command...>`: execute a command inside a running
|
||||
deployment pod over an interactive WebSocket
|
||||
- `restart [deployment]`: roll out a restart across managed deployments
|
||||
- `stop`: scale managed deployments to zero
|
||||
- `stop`: delete the matching name-scoped HPAs (so autoscaling cannot scale
|
||||
replicas back up) and scale managed deployments to zero
|
||||
- `rollback` (alias `fuck`) `[deployment]`: roll one deployment back to its
|
||||
previous release, or all managed deployments when no name is given
|
||||
- `down [-f]`: delete managed resources while keeping ingress, PVCs, managed
|
||||
@@ -485,6 +486,39 @@ Translation rules:
|
||||
- `host:port:protected` -> Traefik `IngressRoute` with middleware `routing/cf-auth` and host-wide matching
|
||||
- `host:port:protected(path1,path2,...)` -> Traefik `IngressRoute` with middleware `routing/cf-auth` and explicit `PathPrefix(...)` matches only
|
||||
|
||||
### Replicas and Autoscaling
|
||||
|
||||
`deploy.replicas` (or the top-level `scale` field) controls the Deployment
|
||||
replica count. A plain integer or numeric string renders a fixed `replicas`
|
||||
value, with `scale` taking precedence over `deploy.replicas`.
|
||||
|
||||
A `"min-max"` range string requests autoscaling instead of a fixed count:
|
||||
|
||||
```yml
|
||||
services:
|
||||
app:
|
||||
image: app
|
||||
deploy:
|
||||
replicas: "2-6"
|
||||
```
|
||||
|
||||
kuber renders:
|
||||
|
||||
- a `Deployment` with `replicas` set to the range minimum (`2`)
|
||||
- a `HorizontalPodAutoscaler` (`autoscaling/v2`) targeting that Deployment,
|
||||
with `minReplicas: 2`, `maxReplicas: 6`, and a CPU target of 80%
|
||||
utilization
|
||||
- a `100m` CPU request injected into the container, unless `x-container`
|
||||
already specifies a CPU request (the HPA needs a CPU request to scale on)
|
||||
|
||||
For both fixed counts above one and autoscaled ranges whose maximum exceeds
|
||||
one, kuber also adds a `topologySpreadConstraints` entry spreading pods across
|
||||
hosts (`kubernetes.io/hostname`, `maxSkew: 1`,
|
||||
`whenUnsatisfiable: ScheduleAnyway`).
|
||||
|
||||
Malformed non-numeric replica values (for example `"lots"`) are rejected with
|
||||
a clear error instead of silently defaulting.
|
||||
|
||||
### Managed Postgres
|
||||
|
||||
You can declare a managed Postgres database with a pseudo-volume:
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ import { users } from "./users";
|
||||
export const main = defineCommand({
|
||||
meta: {
|
||||
name: "kuber",
|
||||
version: "2.0.0",
|
||||
version: "2.1.0",
|
||||
description: "Docker Compose -> K8s translation layer",
|
||||
},
|
||||
args: {
|
||||
|
||||
@@ -118,6 +118,8 @@ The active example uses TCP readiness and liveness probes, so it does not assume
|
||||
|
||||
`deploy.replicas` controls the Deployment replica count. The example starts with one replica and includes conservative CPU and memory requests and limits through `x-container`.
|
||||
|
||||
A `"min-max"` range enables autoscaling: kuber renders a `HorizontalPodAutoscaler` (CPU 80% target) so the Deployment scales between the min and max, and injects a `100m` CPU request if none is set. For example `deploy.replicas: "1-6"`.
|
||||
|
||||
`x-container` is merged directly into the generated Kubernetes container. The example uses it for probes, resources, and a non-root security context matching UID/GID `1001` from the Dockerfile.
|
||||
|
||||
Tune resource values from observed production usage. Memory limits that are too low cause Kubernetes to terminate the process, while requests that are too high make scheduling unnecessarily difficult.
|
||||
|
||||
+4
-2
@@ -20,8 +20,9 @@ const ResourceOrder = {
|
||||
ConfigMap: 11,
|
||||
Service: 12,
|
||||
Deployment: 13,
|
||||
Ingress: 14,
|
||||
IngressRoute: 15,
|
||||
HorizontalPodAutoscaler: 14,
|
||||
Ingress: 15,
|
||||
IngressRoute: 16,
|
||||
} as const;
|
||||
|
||||
const ManagedResources = [
|
||||
@@ -30,6 +31,7 @@ const ManagedResources = [
|
||||
{ apiVersion: "v1", kind: "ConfigMap" },
|
||||
{ apiVersion: "v1", kind: "Service" },
|
||||
{ apiVersion: "apps/v1", kind: "Deployment" },
|
||||
{ apiVersion: "autoscaling/v2", kind: "HorizontalPodAutoscaler" },
|
||||
{ apiVersion: "networking.k8s.io/v1", kind: "Ingress" },
|
||||
{ apiVersion: "traefik.io/v1alpha1", kind: "IngressRoute" },
|
||||
] as const;
|
||||
|
||||
+230
-33
@@ -7,6 +7,7 @@ import type {
|
||||
V1Ingress,
|
||||
V1Namespace,
|
||||
V1PersistentVolumeClaim,
|
||||
V1PodSpec,
|
||||
V1Secret,
|
||||
V1Service,
|
||||
V1ServicePort,
|
||||
@@ -112,10 +113,95 @@ function toPortNumber(value: number | string | undefined): number | undefined {
|
||||
return Number.isInteger(port) ? port : undefined;
|
||||
}
|
||||
|
||||
function toReplicaCount(service: Service): number {
|
||||
return (
|
||||
toPortNumber(service.scale) ?? toPortNumber(service.deploy?.replicas) ?? 1
|
||||
);
|
||||
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(
|
||||
@@ -890,11 +976,55 @@ export function serviceToDeployment(
|
||||
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(
|
||||
{
|
||||
@@ -906,7 +1036,7 @@ export function serviceToDeployment(
|
||||
labels: LABELS,
|
||||
},
|
||||
spec: {
|
||||
replicas: toReplicaCount(service),
|
||||
replicas,
|
||||
strategy: {
|
||||
rollingUpdate: {
|
||||
maxSurge: "25%",
|
||||
@@ -922,33 +1052,7 @@ export function serviceToDeployment(
|
||||
app: name,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
restartPolicy: "Always",
|
||||
...getComposeArchPlacement(compose),
|
||||
volumes: toVolumes(project, mounts),
|
||||
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"] ?? {},
|
||||
),
|
||||
],
|
||||
},
|
||||
spec: podSpec,
|
||||
},
|
||||
},
|
||||
} satisfies V1Deployment,
|
||||
@@ -1171,7 +1275,91 @@ export type KubernetesResource =
|
||||
| V1Deployment
|
||||
| V1Ingress
|
||||
| V1ConfigMap
|
||||
| KubernetesObject;
|
||||
| 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 ?? ""}`;
|
||||
@@ -1235,6 +1423,8 @@ export async function composeToKubernetes(
|
||||
const svc = serviceToSvc(project, name, service);
|
||||
if (svc) resources.set(getResourceKey(svc), svc);
|
||||
|
||||
const replicaSpec = resolveReplicaSpec(service);
|
||||
|
||||
const deployment = serviceToDeployment(
|
||||
project,
|
||||
name,
|
||||
@@ -1245,6 +1435,7 @@ export async function composeToKubernetes(
|
||||
compose.volumes,
|
||||
buildImages,
|
||||
artifacts,
|
||||
{ replicaSpec },
|
||||
);
|
||||
const envSecret = envSecrets[0];
|
||||
if (envSecret) {
|
||||
@@ -1255,6 +1446,12 @@ export async function composeToKubernetes(
|
||||
}
|
||||
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);
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@dmgnr/kuber",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"description": "Docker Compose to Kubernetes translation layer",
|
||||
"bin": {
|
||||
"kuber": "dist/index.js"
|
||||
|
||||
@@ -474,6 +474,19 @@ export function createManagementService(dependencies: ManagementDependencies) {
|
||||
|
||||
async stop(workspace: Workspace, names?: string[]): Promise<string[]> {
|
||||
const selected = await targets(workspace, names);
|
||||
const selectedSet = new Set(selected);
|
||||
const hpas = (await dependencies.listProjectResources(workspace.project))
|
||||
.filter(
|
||||
(resource) =>
|
||||
resource.apiVersion === "autoscaling/v2" &&
|
||||
resource.kind === "HorizontalPodAutoscaler" &&
|
||||
resource.metadata?.name !== undefined &&
|
||||
selectedSet.has(resource.metadata.name),
|
||||
)
|
||||
.map((resource) => identity(workspace, resource));
|
||||
if (hpas.length > 0) {
|
||||
await deleteResources(workspace, hpas);
|
||||
}
|
||||
for (const name of selected) {
|
||||
await dependencies.scaleDeployment(workspace.project, name, 0);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ describe("resource ordering", () => {
|
||||
resource("IngressRoute"),
|
||||
resource("Deployment"),
|
||||
resource("GarageKey"),
|
||||
resource("HorizontalPodAutoscaler"),
|
||||
resource("Service"),
|
||||
resource("GarageBucket"),
|
||||
resource("ConfigMap"),
|
||||
@@ -42,6 +43,7 @@ describe("resource ordering", () => {
|
||||
"ConfigMap",
|
||||
"Service",
|
||||
"Deployment",
|
||||
"HorizontalPodAutoscaler",
|
||||
"Ingress",
|
||||
"IngressRoute",
|
||||
"Database",
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { V1Deployment } from "@kubernetes/client-node";
|
||||
import type { ComposeSpecification, Service } from "../../schema/docker.d";
|
||||
import {
|
||||
composeToKubernetes,
|
||||
composeToNamespace,
|
||||
parseReplicaRange,
|
||||
resolveReplicaSpec,
|
||||
serviceToDeployment,
|
||||
serviceToIngress,
|
||||
serviceToSvc,
|
||||
} from "../../lib/convert";
|
||||
|
||||
function findDeployment(
|
||||
resources: Awaited<ReturnType<typeof composeToKubernetes>>,
|
||||
): V1Deployment {
|
||||
const deployment = resources.find(
|
||||
(resource) => resource.kind === "Deployment",
|
||||
) as V1Deployment | undefined;
|
||||
if (!deployment) throw new Error("No Deployment rendered");
|
||||
return deployment;
|
||||
}
|
||||
|
||||
function findHpa(resources: Awaited<ReturnType<typeof composeToKubernetes>>) {
|
||||
return resources.find(
|
||||
(resource) => resource.kind === "HorizontalPodAutoscaler",
|
||||
);
|
||||
}
|
||||
|
||||
function compose(service: Service, extra: Partial<ComposeSpecification> = {}) {
|
||||
return { ...extra, services: { app: service } } as ComposeSpecification;
|
||||
}
|
||||
@@ -251,6 +270,284 @@ describe("port and routing conversion", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("replica range parsing", () => {
|
||||
test("parses a valid min-max range", () => {
|
||||
expect(parseReplicaRange("2-6")).toEqual({ kind: "range", min: 2, max: 6 });
|
||||
expect(parseReplicaRange("1-1")).toEqual({ kind: "range", min: 1, max: 1 });
|
||||
expect(parseReplicaRange(" 3 - 9 ")).toEqual({
|
||||
kind: "range",
|
||||
min: 3,
|
||||
max: 9,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects ranges where min exceeds max", () => {
|
||||
expect(() => parseReplicaRange("6-2")).toThrow(
|
||||
"Minimum (6) cannot exceed maximum (2)",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects non-positive range bounds", () => {
|
||||
expect(() => parseReplicaRange("0-4")).toThrow(
|
||||
"Range bounds must be positive integers",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns undefined for non-range values", () => {
|
||||
expect(parseReplicaRange("3")).toBeUndefined();
|
||||
expect(parseReplicaRange(3)).toBeUndefined();
|
||||
expect(parseReplicaRange("abc")).toBeUndefined();
|
||||
expect(parseReplicaRange(undefined)).toBeUndefined();
|
||||
expect(parseReplicaRange("3-")).toBeUndefined();
|
||||
expect(parseReplicaRange("-6")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("replica spec resolution", () => {
|
||||
test("prefers a scale range over deploy but only uses scale when set", () => {
|
||||
expect(
|
||||
resolveReplicaSpec({ image: "app", scale: "2-6", "deploy": { replicas: 3 } } as Service),
|
||||
).toEqual({ kind: "range", min: 2, max: 6 });
|
||||
});
|
||||
|
||||
test("uses deploy range when scale is unset", () => {
|
||||
expect(
|
||||
resolveReplicaSpec({ image: "app", "deploy": { replicas: "2-6" } } as Service),
|
||||
).toEqual({ kind: "range", min: 2, max: 6 });
|
||||
});
|
||||
|
||||
test("prefers a fixed scale value over a deploy range", () => {
|
||||
expect(
|
||||
resolveReplicaSpec({ image: "app", scale: 3, "deploy": { replicas: "2-6" } } as Service),
|
||||
).toEqual({ kind: "fixed", value: 3 });
|
||||
});
|
||||
|
||||
test("preserves existing scale over deploy replicas precedence", () => {
|
||||
expect(
|
||||
resolveReplicaSpec({ image: "app", scale: 4, "deploy": { replicas: 2 } } as Service),
|
||||
).toEqual({ kind: "fixed", value: 4 });
|
||||
expect(
|
||||
resolveReplicaSpec({ image: "app", "deploy": { replicas: 2 } } as Service),
|
||||
).toEqual({ kind: "fixed", value: 2 });
|
||||
});
|
||||
|
||||
test("supports a fixed zero replica count via scale and deploy.replicas", () => {
|
||||
expect(
|
||||
resolveReplicaSpec({ image: "app", scale: 0 } as Service),
|
||||
).toEqual({ kind: "fixed", value: 0 });
|
||||
expect(
|
||||
resolveReplicaSpec({ image: "app", scale: "0" } as Service),
|
||||
).toEqual({ kind: "fixed", value: 0 });
|
||||
expect(
|
||||
resolveReplicaSpec({ image: "app", "deploy": { replicas: 0 } } as Service),
|
||||
).toEqual({ kind: "fixed", value: 0 });
|
||||
expect(
|
||||
resolveReplicaSpec({ image: "app", "deploy": { replicas: "0" } } as Service),
|
||||
).toEqual({ kind: "fixed", value: 0 });
|
||||
});
|
||||
|
||||
test("normalizes an equal scale range to a fixed replica spec", () => {
|
||||
expect(
|
||||
resolveReplicaSpec({ image: "app", scale: "3-3" } as Service),
|
||||
).toEqual({ kind: "fixed", value: 3 });
|
||||
});
|
||||
|
||||
test("normalizes an equal deploy range to a fixed replica spec", () => {
|
||||
expect(
|
||||
resolveReplicaSpec({ image: "app", "deploy": { replicas: "1-1" } } as Service),
|
||||
).toEqual({ kind: "fixed", value: 1 });
|
||||
expect(
|
||||
resolveReplicaSpec({ image: "app", "deploy": { replicas: "3-3" } } as Service),
|
||||
).toEqual({ kind: "fixed", value: 3 });
|
||||
});
|
||||
|
||||
test("keeps an unequal range as a range spec", () => {
|
||||
expect(
|
||||
resolveReplicaSpec({ image: "app", "deploy": { replicas: "1-4" } } as Service),
|
||||
).toEqual({ kind: "range", min: 1, max: 4 });
|
||||
});
|
||||
|
||||
test("throws on malformed nonnumeric replica strings", () => {
|
||||
expect(() =>
|
||||
resolveReplicaSpec({ image: "app", scale: "fifty" } as Service),
|
||||
).toThrow("Invalid scale value");
|
||||
expect(() =>
|
||||
resolveReplicaSpec({ image: "app", "deploy": { replicas: "bah" } } as Service),
|
||||
).toThrow("Invalid deploy.replicas value");
|
||||
});
|
||||
|
||||
test("defaults to a single replica", () => {
|
||||
expect(resolveReplicaSpec({ image: "app" } as Service)).toEqual({
|
||||
kind: "fixed",
|
||||
value: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoscaled (range) conversion", () => {
|
||||
test("sets deployment replicas to the range min and renders an HPA", async () => {
|
||||
const service = {
|
||||
image: "app",
|
||||
"deploy": { replicas: "2-6" },
|
||||
} as Service;
|
||||
const resources = await composeToKubernetes("project", compose(service));
|
||||
|
||||
const deployment = findDeployment(resources);
|
||||
expect(deployment.spec).toMatchObject({ replicas: 2 });
|
||||
|
||||
const hpa = findHpa(resources);
|
||||
expect(hpa).toMatchObject({
|
||||
apiVersion: "autoscaling/v2",
|
||||
kind: "HorizontalPodAutoscaler",
|
||||
metadata: { name: "app", namespace: "project" },
|
||||
spec: {
|
||||
scaleTargetRef: { apiVersion: "apps/v1", kind: "Deployment", name: "app" },
|
||||
minReplicas: 2,
|
||||
maxReplicas: 6,
|
||||
metrics: [
|
||||
{
|
||||
type: "Resource",
|
||||
resource: {
|
||||
name: "cpu",
|
||||
target: { type: "Utilization", averageUtilization: 80 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("injects a CPU request on autoscaled services without one", async () => {
|
||||
const service = { image: "app", "deploy": { replicas: "1-4" } } as Service;
|
||||
const resources = await composeToKubernetes("project", compose(service));
|
||||
const deployment = findDeployment(resources);
|
||||
expect(
|
||||
deployment.spec?.template?.spec?.containers?.[0]?.resources?.requests,
|
||||
).toEqual({ cpu: "100m" });
|
||||
});
|
||||
|
||||
test("does not override an existing CPU request from x-container", async () => {
|
||||
const service = {
|
||||
image: "app",
|
||||
"deploy": { replicas: "1-4" },
|
||||
"x-container": { resources: { requests: { cpu: "500m" } } },
|
||||
} as Service;
|
||||
const resources = await composeToKubernetes("project", compose(service));
|
||||
const deployment = findDeployment(resources);
|
||||
expect(
|
||||
deployment.spec?.template?.spec?.containers?.[0]?.resources?.requests,
|
||||
).toEqual({ cpu: "500m" });
|
||||
});
|
||||
|
||||
test("does not inject a CPU request on fixed-replica services", async () => {
|
||||
const service = { image: "app" } as Service;
|
||||
const resources = await composeToKubernetes("project", compose(service));
|
||||
const deployment = findDeployment(resources);
|
||||
expect(
|
||||
deployment.spec?.template.spec?.containers?.[0]?.resources,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("treats an equal range as fixed: no HPA and no injected CPU request", async () => {
|
||||
const service = {
|
||||
image: "app",
|
||||
"deploy": { replicas: "1-1" },
|
||||
} as Service;
|
||||
const resources = await composeToKubernetes("project", compose(service));
|
||||
const deployment = findDeployment(resources);
|
||||
expect(deployment.spec?.replicas).toBe(1);
|
||||
expect(findHpa(resources)).toBeUndefined();
|
||||
expect(
|
||||
deployment.spec?.template.spec?.containers?.[0]?.resources,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("treats an equal range > 1 as fixed: no HPA or CPU request but keeps spread", async () => {
|
||||
const service = {
|
||||
image: "app",
|
||||
"deploy": { replicas: "3-3" },
|
||||
} as Service;
|
||||
const resources = await composeToKubernetes("project", compose(service));
|
||||
const deployment = findDeployment(resources);
|
||||
expect(deployment.spec?.replicas).toBe(3);
|
||||
expect(findHpa(resources)).toBeUndefined();
|
||||
expect(
|
||||
deployment.spec?.template.spec?.containers?.[0]?.resources,
|
||||
).toBeUndefined();
|
||||
expect(deployment.spec?.template.spec?.topologySpreadConstraints).toEqual([
|
||||
{
|
||||
maxSkew: 1,
|
||||
topologyKey: "kubernetes.io/hostname",
|
||||
whenUnsatisfiable: "ScheduleAnyway",
|
||||
labelSelector: { matchLabels: { app: "app" } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("topology spread constraints", () => {
|
||||
test("adds spread on static replicas > 1", () => {
|
||||
const deployment = serviceToDeployment(
|
||||
"project",
|
||||
"app",
|
||||
compose({ image: "app", scale: 3 } as Service),
|
||||
{ image: "app", scale: 3 } as Service,
|
||||
);
|
||||
expect(deployment.spec?.template.spec?.topologySpreadConstraints).toEqual([
|
||||
{
|
||||
maxSkew: 1,
|
||||
topologyKey: "kubernetes.io/hostname",
|
||||
whenUnsatisfiable: "ScheduleAnyway",
|
||||
labelSelector: { matchLabels: { app: "app" } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("adds spread when range max exceeds one", async () => {
|
||||
const resources = await composeToKubernetes(
|
||||
"project",
|
||||
compose({
|
||||
image: "app",
|
||||
"deploy": { replicas: "1-4" },
|
||||
} as Service),
|
||||
);
|
||||
const deployment = findDeployment(resources);
|
||||
expect(deployment.spec?.template.spec?.topologySpreadConstraints).toEqual([
|
||||
{
|
||||
maxSkew: 1,
|
||||
topologyKey: "kubernetes.io/hostname",
|
||||
whenUnsatisfiable: "ScheduleAnyway",
|
||||
labelSelector: { matchLabels: { app: "app" } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("omits spread for single-replica services", () => {
|
||||
const deployment = serviceToDeployment(
|
||||
"project",
|
||||
"app",
|
||||
compose({ image: "app" } as Service),
|
||||
{ image: "app" } as Service,
|
||||
);
|
||||
expect(
|
||||
deployment.spec?.template.spec?.topologySpreadConstraints,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("supports a fixed zero replicas deployment with no HPA or spread", async () => {
|
||||
const service = { image: "app", "deploy": { replicas: 0 } } as Service;
|
||||
const resources = await composeToKubernetes("project", compose(service));
|
||||
const deployment = findDeployment(resources);
|
||||
expect(deployment.spec?.replicas).toBe(0);
|
||||
expect(findHpa(resources)).toBeUndefined();
|
||||
expect(
|
||||
deployment.spec?.template.spec?.topologySpreadConstraints,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("namespace conversion", () => {
|
||||
test("renders a managed namespace", () => {
|
||||
expect(composeToNamespace("project")).toEqual({
|
||||
|
||||
@@ -20,7 +20,12 @@ function object(
|
||||
},
|
||||
): KubernetesObject {
|
||||
return {
|
||||
apiVersion: kind === "Deployment" ? "apps/v1" : "v1",
|
||||
apiVersion:
|
||||
kind === "Deployment"
|
||||
? "apps/v1"
|
||||
: kind === "HorizontalPodAutoscaler"
|
||||
? "autoscaling/v2"
|
||||
: "v1",
|
||||
kind,
|
||||
metadata: { name, namespace: workspace.project, uid, labels },
|
||||
};
|
||||
@@ -222,6 +227,115 @@ describe("server management service", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("stop deletes matching HPAs before scaling selected deployments", async () => {
|
||||
const calls: string[] = [];
|
||||
const service = createManagementService(
|
||||
dependencies({
|
||||
listDeployments: async () =>
|
||||
["api", "worker", "batch"].map(
|
||||
(name) =>
|
||||
object("Deployment", name, `${name}-uid`) as V1Deployment,
|
||||
),
|
||||
listProjectResources: async () => [
|
||||
object("HorizontalPodAutoscaler", "api", "api-hpa-uid", undefined),
|
||||
object("HorizontalPodAutoscaler", "worker", "worker-hpa-uid", undefined),
|
||||
object("HorizontalPodAutoscaler", "batch", "batch-hpa-uid", undefined),
|
||||
],
|
||||
scaleDeployment: async (_project, name, replicas) => {
|
||||
calls.push(`scale:${name}:${replicas}`);
|
||||
},
|
||||
deleteResource: async (item) => {
|
||||
calls.push(`delete:${item.kind}:${item.name}`);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await service.stop(workspace, ["api", "worker"])).toEqual([
|
||||
"api",
|
||||
"worker",
|
||||
]);
|
||||
expect(calls).toEqual([
|
||||
"delete:HorizontalPodAutoscaler:api",
|
||||
"delete:HorizontalPodAutoscaler:worker",
|
||||
"scale:api:0",
|
||||
"scale:worker:0",
|
||||
]);
|
||||
});
|
||||
|
||||
test("stop deletes HPAs for all deployments and leaves unrelated HPAs alone", async () => {
|
||||
const calls: string[] = [];
|
||||
const service = createManagementService(
|
||||
dependencies({
|
||||
listDeployments: async () =>
|
||||
["api", "worker"].map(
|
||||
(name) =>
|
||||
object("Deployment", name, `${name}-uid`) as V1Deployment,
|
||||
),
|
||||
listProjectResources: async () => [
|
||||
object("HorizontalPodAutoscaler", "api", "api-hpa-uid", undefined),
|
||||
object("HorizontalPodAutoscaler", "worker", "worker-hpa-uid", undefined),
|
||||
object("HorizontalPodAutoscaler", "legacy", "legacy-hpa-uid", undefined),
|
||||
],
|
||||
scaleDeployment: async (_project, name, replicas) => {
|
||||
calls.push(`scale:${name}:${replicas}`);
|
||||
},
|
||||
deleteResource: async (item) => {
|
||||
calls.push(`delete:${item.kind}:${item.name}`);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await service.stop(workspace)).toEqual(["api", "worker"]);
|
||||
expect(calls).toEqual([
|
||||
"delete:HorizontalPodAutoscaler:api",
|
||||
"delete:HorizontalPodAutoscaler:worker",
|
||||
"scale:api:0",
|
||||
"scale:worker:0",
|
||||
]);
|
||||
});
|
||||
|
||||
test("refuses to delete an HPA not owned by the workspace", async () => {
|
||||
const service = createManagementService(
|
||||
dependencies({
|
||||
listDeployments: async () =>
|
||||
[object("Deployment", "api", "api-uid") as V1Deployment],
|
||||
listProjectResources: async () => [
|
||||
object("HorizontalPodAutoscaler", "api", "api-hpa-uid", {
|
||||
"app.kubernetes.io/managed-by": "kuber",
|
||||
[WORKSPACE_UID_LABEL]: "someone-else",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(service.stop(workspace, ["api"])).rejects.toThrow(
|
||||
"is not owned by workspace",
|
||||
);
|
||||
});
|
||||
|
||||
test("stop scales deployments to zero when there are no HPAs", async () => {
|
||||
const calls: string[] = [];
|
||||
const service = createManagementService(
|
||||
dependencies({
|
||||
listDeployments: async () =>
|
||||
["api", "worker"].map(
|
||||
(name) =>
|
||||
object("Deployment", name, `${name}-uid`) as V1Deployment,
|
||||
),
|
||||
listProjectResources: async () => [
|
||||
object("ConfigMap", "env", "env-uid"),
|
||||
object("Service", "api", "api-svc-uid"),
|
||||
],
|
||||
scaleDeployment: async (_project, name, replicas) => {
|
||||
calls.push(`scale:${name}:${replicas}`);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await service.stop(workspace)).toEqual(["api", "worker"]);
|
||||
expect(calls).toEqual(["scale:api:0", "scale:worker:0"]);
|
||||
});
|
||||
|
||||
test("protects reserved namespaces before consulting Kubernetes", async () => {
|
||||
let read = false;
|
||||
const service = createManagementService(
|
||||
|
||||
Reference in New Issue
Block a user