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
|
- `exec <deployment> <command...>`: execute a command inside a running
|
||||||
deployment pod over an interactive WebSocket
|
deployment pod over an interactive WebSocket
|
||||||
- `restart [deployment]`: roll out a restart across managed deployments
|
- `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
|
- `rollback` (alias `fuck`) `[deployment]`: roll one deployment back to its
|
||||||
previous release, or all managed deployments when no name is given
|
previous release, or all managed deployments when no name is given
|
||||||
- `down [-f]`: delete managed resources while keeping ingress, PVCs, managed
|
- `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` -> 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
|
- `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
|
### Managed Postgres
|
||||||
|
|
||||||
You can declare a managed Postgres database with a pseudo-volume:
|
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({
|
export const main = defineCommand({
|
||||||
meta: {
|
meta: {
|
||||||
name: "kuber",
|
name: "kuber",
|
||||||
version: "2.0.0",
|
version: "2.1.0",
|
||||||
description: "Docker Compose -> K8s translation layer",
|
description: "Docker Compose -> K8s translation layer",
|
||||||
},
|
},
|
||||||
args: {
|
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`.
|
`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.
|
`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.
|
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,
|
ConfigMap: 11,
|
||||||
Service: 12,
|
Service: 12,
|
||||||
Deployment: 13,
|
Deployment: 13,
|
||||||
Ingress: 14,
|
HorizontalPodAutoscaler: 14,
|
||||||
IngressRoute: 15,
|
Ingress: 15,
|
||||||
|
IngressRoute: 16,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const ManagedResources = [
|
const ManagedResources = [
|
||||||
@@ -30,6 +31,7 @@ const ManagedResources = [
|
|||||||
{ apiVersion: "v1", kind: "ConfigMap" },
|
{ apiVersion: "v1", kind: "ConfigMap" },
|
||||||
{ apiVersion: "v1", kind: "Service" },
|
{ apiVersion: "v1", kind: "Service" },
|
||||||
{ apiVersion: "apps/v1", kind: "Deployment" },
|
{ apiVersion: "apps/v1", kind: "Deployment" },
|
||||||
|
{ apiVersion: "autoscaling/v2", kind: "HorizontalPodAutoscaler" },
|
||||||
{ apiVersion: "networking.k8s.io/v1", kind: "Ingress" },
|
{ apiVersion: "networking.k8s.io/v1", kind: "Ingress" },
|
||||||
{ apiVersion: "traefik.io/v1alpha1", kind: "IngressRoute" },
|
{ apiVersion: "traefik.io/v1alpha1", kind: "IngressRoute" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
+230
-33
@@ -7,6 +7,7 @@ import type {
|
|||||||
V1Ingress,
|
V1Ingress,
|
||||||
V1Namespace,
|
V1Namespace,
|
||||||
V1PersistentVolumeClaim,
|
V1PersistentVolumeClaim,
|
||||||
|
V1PodSpec,
|
||||||
V1Secret,
|
V1Secret,
|
||||||
V1Service,
|
V1Service,
|
||||||
V1ServicePort,
|
V1ServicePort,
|
||||||
@@ -112,10 +113,95 @@ function toPortNumber(value: number | string | undefined): number | undefined {
|
|||||||
return Number.isInteger(port) ? port : undefined;
|
return Number.isInteger(port) ? port : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toReplicaCount(service: Service): number {
|
export type ReplicaRange = { kind: "range"; min: number; max: number };
|
||||||
return (
|
export type ReplicaFixed = { kind: "fixed"; value: number };
|
||||||
toPortNumber(service.scale) ?? toPortNumber(service.deploy?.replicas) ?? 1
|
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(
|
function toDeploymentImage(
|
||||||
@@ -890,11 +976,55 @@ export function serviceToDeployment(
|
|||||||
volumes: ComposeVolumes = {},
|
volumes: ComposeVolumes = {},
|
||||||
buildImages: Record<string, string> = {},
|
buildImages: Record<string, string> = {},
|
||||||
artifacts: ArtifactProvider = new LocalArtifactProvider({ workspace: cwd }),
|
artifacts: ArtifactProvider = new LocalArtifactProvider({ workspace: cwd }),
|
||||||
|
options: { replicaSpec?: ReplicaSpec } = {},
|
||||||
): V1Deployment {
|
): V1Deployment {
|
||||||
const mounts = toMounts(service, artifacts, volumes);
|
const mounts = toMounts(service, artifacts, volumes);
|
||||||
const ports = toPorts(service);
|
const ports = toPorts(service);
|
||||||
const hasEnvSecret =
|
const hasEnvSecret =
|
||||||
Boolean(service.env_file) || Object.keys(extraEnv).length > 0;
|
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(
|
return deepMerge(
|
||||||
{
|
{
|
||||||
@@ -906,7 +1036,7 @@ export function serviceToDeployment(
|
|||||||
labels: LABELS,
|
labels: LABELS,
|
||||||
},
|
},
|
||||||
spec: {
|
spec: {
|
||||||
replicas: toReplicaCount(service),
|
replicas,
|
||||||
strategy: {
|
strategy: {
|
||||||
rollingUpdate: {
|
rollingUpdate: {
|
||||||
maxSurge: "25%",
|
maxSurge: "25%",
|
||||||
@@ -922,33 +1052,7 @@ export function serviceToDeployment(
|
|||||||
app: name,
|
app: name,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
spec: {
|
spec: podSpec,
|
||||||
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"] ?? {},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} satisfies V1Deployment,
|
} satisfies V1Deployment,
|
||||||
@@ -1171,7 +1275,91 @@ export type KubernetesResource =
|
|||||||
| V1Deployment
|
| V1Deployment
|
||||||
| V1Ingress
|
| V1Ingress
|
||||||
| V1ConfigMap
|
| 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 {
|
function getResourceKey(resource: KubernetesObject): string {
|
||||||
return `${resource.kind}:${resource.metadata?.namespace ?? ""}:${resource.metadata?.name ?? ""}`;
|
return `${resource.kind}:${resource.metadata?.namespace ?? ""}:${resource.metadata?.name ?? ""}`;
|
||||||
@@ -1235,6 +1423,8 @@ export async function composeToKubernetes(
|
|||||||
const svc = serviceToSvc(project, name, service);
|
const svc = serviceToSvc(project, name, service);
|
||||||
if (svc) resources.set(getResourceKey(svc), svc);
|
if (svc) resources.set(getResourceKey(svc), svc);
|
||||||
|
|
||||||
|
const replicaSpec = resolveReplicaSpec(service);
|
||||||
|
|
||||||
const deployment = serviceToDeployment(
|
const deployment = serviceToDeployment(
|
||||||
project,
|
project,
|
||||||
name,
|
name,
|
||||||
@@ -1245,6 +1435,7 @@ export async function composeToKubernetes(
|
|||||||
compose.volumes,
|
compose.volumes,
|
||||||
buildImages,
|
buildImages,
|
||||||
artifacts,
|
artifacts,
|
||||||
|
{ replicaSpec },
|
||||||
);
|
);
|
||||||
const envSecret = envSecrets[0];
|
const envSecret = envSecrets[0];
|
||||||
if (envSecret) {
|
if (envSecret) {
|
||||||
@@ -1255,6 +1446,12 @@ export async function composeToKubernetes(
|
|||||||
}
|
}
|
||||||
resources.set(getResourceKey(deployment), deployment);
|
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);
|
const ingress = serviceToIngress(project, name, service);
|
||||||
if (ingress) resources.set(getResourceKey(ingress), ingress);
|
if (ingress) resources.set(getResourceKey(ingress), ingress);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@dmgnr/kuber",
|
"name": "@dmgnr/kuber",
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"description": "Docker Compose to Kubernetes translation layer",
|
"description": "Docker Compose to Kubernetes translation layer",
|
||||||
"bin": {
|
"bin": {
|
||||||
"kuber": "dist/index.js"
|
"kuber": "dist/index.js"
|
||||||
|
|||||||
@@ -474,6 +474,19 @@ export function createManagementService(dependencies: ManagementDependencies) {
|
|||||||
|
|
||||||
async stop(workspace: Workspace, names?: string[]): Promise<string[]> {
|
async stop(workspace: Workspace, names?: string[]): Promise<string[]> {
|
||||||
const selected = await targets(workspace, names);
|
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) {
|
for (const name of selected) {
|
||||||
await dependencies.scaleDeployment(workspace.project, name, 0);
|
await dependencies.scaleDeployment(workspace.project, name, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ describe("resource ordering", () => {
|
|||||||
resource("IngressRoute"),
|
resource("IngressRoute"),
|
||||||
resource("Deployment"),
|
resource("Deployment"),
|
||||||
resource("GarageKey"),
|
resource("GarageKey"),
|
||||||
|
resource("HorizontalPodAutoscaler"),
|
||||||
resource("Service"),
|
resource("Service"),
|
||||||
resource("GarageBucket"),
|
resource("GarageBucket"),
|
||||||
resource("ConfigMap"),
|
resource("ConfigMap"),
|
||||||
@@ -42,6 +43,7 @@ describe("resource ordering", () => {
|
|||||||
"ConfigMap",
|
"ConfigMap",
|
||||||
"Service",
|
"Service",
|
||||||
"Deployment",
|
"Deployment",
|
||||||
|
"HorizontalPodAutoscaler",
|
||||||
"Ingress",
|
"Ingress",
|
||||||
"IngressRoute",
|
"IngressRoute",
|
||||||
"Database",
|
"Database",
|
||||||
|
|||||||
@@ -1,13 +1,32 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import type { V1Deployment } from "@kubernetes/client-node";
|
||||||
import type { ComposeSpecification, Service } from "../../schema/docker.d";
|
import type { ComposeSpecification, Service } from "../../schema/docker.d";
|
||||||
import {
|
import {
|
||||||
composeToKubernetes,
|
composeToKubernetes,
|
||||||
composeToNamespace,
|
composeToNamespace,
|
||||||
|
parseReplicaRange,
|
||||||
|
resolveReplicaSpec,
|
||||||
serviceToDeployment,
|
serviceToDeployment,
|
||||||
serviceToIngress,
|
serviceToIngress,
|
||||||
serviceToSvc,
|
serviceToSvc,
|
||||||
} from "../../lib/convert";
|
} 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> = {}) {
|
function compose(service: Service, extra: Partial<ComposeSpecification> = {}) {
|
||||||
return { ...extra, services: { app: service } } as 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", () => {
|
describe("namespace conversion", () => {
|
||||||
test("renders a managed namespace", () => {
|
test("renders a managed namespace", () => {
|
||||||
expect(composeToNamespace("project")).toEqual({
|
expect(composeToNamespace("project")).toEqual({
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ function object(
|
|||||||
},
|
},
|
||||||
): KubernetesObject {
|
): KubernetesObject {
|
||||||
return {
|
return {
|
||||||
apiVersion: kind === "Deployment" ? "apps/v1" : "v1",
|
apiVersion:
|
||||||
|
kind === "Deployment"
|
||||||
|
? "apps/v1"
|
||||||
|
: kind === "HorizontalPodAutoscaler"
|
||||||
|
? "autoscaling/v2"
|
||||||
|
: "v1",
|
||||||
kind,
|
kind,
|
||||||
metadata: { name, namespace: workspace.project, uid, labels },
|
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 () => {
|
test("protects reserved namespaces before consulting Kubernetes", async () => {
|
||||||
let read = false;
|
let read = false;
|
||||||
const service = createManagementService(
|
const service = createManagementService(
|
||||||
|
|||||||
Reference in New Issue
Block a user