feat: add support for replica ranges and HorizontalPodAutoscaler integration

This commit is contained in:
2026-09-03 12:02:22 +07:00 Unverified
parent 4ce8ed3e40
commit 8e9d207915
10 changed files with 700 additions and 39 deletions
+35 -1
View File
@@ -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
View File
@@ -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: {
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+13
View File
@@ -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);
}
+2
View File
@@ -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",
+297
View File
@@ -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({
+115 -1
View File
@@ -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(