feat: implement rollback command for deployments
- Added a new `rollback` command to roll back deployments to their previous release. - Introduced `findRollbackCandidates` and `rollbackDeployment` functions to handle rollback logic. - Updated CLI command definitions to include the new `rollback` command with an alias. - Enhanced deployment conversion to use resolved images for buildable services. - Introduced a new `graph` module to visualize Kubernetes resources and their relationships. - Added tests for the new rollback functionality and graph rendering. - Updated package version to 1.3.0.
This commit is contained in:
@@ -7,6 +7,7 @@ import { logs } from "../../command/logs";
|
||||
import { initializeCompletion, main } from "../../command/main";
|
||||
import { ps } from "../../command/ps";
|
||||
import { restart } from "../../command/restart";
|
||||
import { rollback } from "../../command/rollback";
|
||||
import { s3 } from "../../command/s3";
|
||||
import { start } from "../../command/start";
|
||||
import { stop } from "../../command/stop";
|
||||
@@ -25,11 +26,11 @@ describe("CLI command definitions", () => {
|
||||
["start", "Start deployments without rebuilding images", start],
|
||||
["stop", "Scale managed deployments to zero", stop],
|
||||
["restart", "Roll out a restart for managed deployments", restart],
|
||||
["rollback", "Roll deployments back to the previous release", rollback],
|
||||
["s3", "Inspect managed S3 storage", s3],
|
||||
["down", "Delete managed resources except ingress and PVCs", down],
|
||||
["ps", "List deployments", ps],
|
||||
["logs", "Show deployment logs", logs],
|
||||
["exec", "Execute a command inside a deployment pod", exec],
|
||||
["logs", "Show deployment logs", logs], ["exec", "Execute a command inside a deployment pod", exec],
|
||||
["export", "Write rendered manifests to a YAML file", exportCommand],
|
||||
["db", "Inspect managed postgres databases", db],
|
||||
] as const;
|
||||
@@ -38,12 +39,16 @@ describe("CLI command definitions", () => {
|
||||
"defines %s command metadata",
|
||||
(name, description, command) => {
|
||||
const definition = command as Command;
|
||||
expect(definition.meta).toEqual({ name, description });
|
||||
expect(definition.meta).toMatchObject({ name, description });
|
||||
if (name !== "db" && name !== "s3")
|
||||
expect(typeof definition.run).toBe("function");
|
||||
},
|
||||
);
|
||||
|
||||
test("defines the rollback command alias", () => {
|
||||
expect((rollback as Command).meta).toMatchObject({ alias: "fuck" });
|
||||
});
|
||||
|
||||
test("defines build, cleanup, listing, logging, and export flags", () => {
|
||||
expect((up as Command).args?.build).toMatchObject({
|
||||
type: "boolean",
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("up task selection", () => {
|
||||
} as ComposeSpecification,
|
||||
process.cwd(),
|
||||
),
|
||||
).toEqual({ built: [], changed: [] });
|
||||
).toEqual({ built: [], changed: [], images: {} });
|
||||
});
|
||||
|
||||
test("restarts only for changed or unknown image digests", () => {
|
||||
|
||||
@@ -102,13 +102,13 @@ describe("kuber config", () => {
|
||||
{ services: { app: { build: "." } } },
|
||||
process.cwd(),
|
||||
{},
|
||||
"registry.example.com/team",
|
||||
{ app: `registry.example.com/team/kuber/project-app:latest@sha256:${"a".repeat(64)}` },
|
||||
);
|
||||
const deployment = resources.find(
|
||||
(resource) => resource.kind === "Deployment",
|
||||
) as V1Deployment | undefined;
|
||||
expect(deployment?.spec?.template.spec?.containers[0]?.image).toBe(
|
||||
"registry.example.com/team/kuber/project-app:latest",
|
||||
`registry.example.com/team/kuber/project-app:latest@sha256:${"a".repeat(64)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,17 +45,25 @@ describe("Deployment conversion", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("uses the registry image for buildable services", () => {
|
||||
test("uses the resolved image for buildable services and rejects missing ones", () => {
|
||||
const service = { build: ".", image: "ignored:latest" } as Service;
|
||||
const digest = `sha256:${"a".repeat(64)}`;
|
||||
const deployment = serviceToDeployment(
|
||||
"project",
|
||||
"web",
|
||||
compose(service),
|
||||
service,
|
||||
process.cwd(),
|
||||
{},
|
||||
{},
|
||||
{ web: `registry.neko-piranha.ts.net/kuber/project-web:latest@${digest}` },
|
||||
);
|
||||
expect(deployment.spec?.template.spec?.containers[0]?.image).toBe(
|
||||
"registry.neko-piranha.ts.net/kuber/project-web:latest",
|
||||
`registry.neko-piranha.ts.net/kuber/project-web:latest@${digest}`,
|
||||
);
|
||||
expect(() =>
|
||||
serviceToDeployment("project", "web", compose(service), service),
|
||||
).toThrow("Missing resolved build image for service web");
|
||||
});
|
||||
|
||||
test("prefers scale over deploy replicas and accepts command arrays", () => {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { KubernetesObject } from "@kubernetes/client-node";
|
||||
import {
|
||||
buildNamespaceGraphs,
|
||||
deriveNodeStatus,
|
||||
renderNamespaceGraphs,
|
||||
} from "../../lib/graph";
|
||||
|
||||
type TestObject = KubernetesObject & {
|
||||
spec?: Record<string, unknown>;
|
||||
status?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const object = (
|
||||
kind: string,
|
||||
name: string,
|
||||
extra: Partial<TestObject> = {},
|
||||
): TestObject => {
|
||||
const { metadata, ...rest } = extra;
|
||||
return {
|
||||
apiVersion: "v1",
|
||||
kind,
|
||||
...rest,
|
||||
metadata: { name, namespace: "demo", ...metadata },
|
||||
};
|
||||
};
|
||||
|
||||
describe("ANSI resource graph", () => {
|
||||
test("builds a deterministic owner tree and prefers owner edges", () => {
|
||||
const deployment = object("Deployment", "web", {
|
||||
status: { replicas: 1, readyReplicas: 1 },
|
||||
});
|
||||
const replicaSet = object("ReplicaSet", "web-123", {
|
||||
metadata: {
|
||||
ownerReferences: [
|
||||
{ apiVersion: "apps/v1", kind: "Deployment", name: "web", uid: "1" },
|
||||
],
|
||||
},
|
||||
status: { replicas: 1, readyReplicas: 1 },
|
||||
});
|
||||
const pod = object("Pod", "web-123-abc", {
|
||||
metadata: {
|
||||
labels: { app: "web" },
|
||||
ownerReferences: [
|
||||
{
|
||||
apiVersion: "apps/v1",
|
||||
kind: "ReplicaSet",
|
||||
name: "web-123",
|
||||
uid: "2",
|
||||
},
|
||||
],
|
||||
},
|
||||
status: {
|
||||
phase: "Running",
|
||||
conditions: [{ type: "Ready", status: "True" }],
|
||||
},
|
||||
});
|
||||
const service = object("Service", "web", {
|
||||
spec: { selector: { app: "web" } },
|
||||
});
|
||||
|
||||
const [graph] = buildNamespaceGraphs([
|
||||
pod,
|
||||
service,
|
||||
replicaSet,
|
||||
deployment,
|
||||
]);
|
||||
expect(graph?.roots.map((node) => node.id)).toEqual([
|
||||
"Deployment/web",
|
||||
"Service/web",
|
||||
]);
|
||||
expect(graph?.roots[0]?.children[0]?.id).toBe("ReplicaSet/web-123");
|
||||
expect(graph?.roots[0]?.children[0]?.children[0]?.id).toBe(
|
||||
"Pod/web-123-abc",
|
||||
);
|
||||
expect(graph?.roots[1]?.status).toEqual({ label: "1 ep", level: "good" });
|
||||
});
|
||||
|
||||
test("hides stopped Deployment subtrees unless includeIdle is set", () => {
|
||||
const deployment = object("Deployment", "stopped", {
|
||||
status: { replicas: 0, readyReplicas: 0 },
|
||||
});
|
||||
const replicaSet = object("ReplicaSet", "stopped-old", {
|
||||
metadata: {
|
||||
ownerReferences: [
|
||||
{
|
||||
apiVersion: "apps/v1",
|
||||
kind: "Deployment",
|
||||
name: "stopped",
|
||||
uid: "1",
|
||||
},
|
||||
],
|
||||
},
|
||||
status: { replicas: 0, readyReplicas: 0 },
|
||||
});
|
||||
const pod = object("Pod", "stopped-old-pod", {
|
||||
metadata: {
|
||||
ownerReferences: [
|
||||
{
|
||||
apiVersion: "apps/v1",
|
||||
kind: "ReplicaSet",
|
||||
name: "stopped-old",
|
||||
uid: "2",
|
||||
},
|
||||
],
|
||||
},
|
||||
status: { phase: "Failed" },
|
||||
});
|
||||
|
||||
expect(
|
||||
buildNamespaceGraphs([deployment, replicaSet, pod])[0]?.roots,
|
||||
).toEqual([]);
|
||||
const roots = buildNamespaceGraphs([deployment, replicaSet, pod], {
|
||||
includeIdle: true,
|
||||
})[0]?.roots;
|
||||
expect(roots?.[0]?.id).toBe("Deployment/stopped");
|
||||
expect(roots?.[0]?.children[0]?.children[0]?.id).toBe(
|
||||
"Pod/stopped-old-pod",
|
||||
);
|
||||
});
|
||||
|
||||
test("derives good, warning, and bad statuses", () => {
|
||||
expect(
|
||||
deriveNodeStatus(
|
||||
object("Pod", "ready", {
|
||||
status: {
|
||||
phase: "Running",
|
||||
conditions: [{ type: "Ready", status: "True" }],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({ label: "ready", level: "good" });
|
||||
expect(
|
||||
deriveNodeStatus(
|
||||
object("Deployment", "progressing", {
|
||||
status: { replicas: 3, readyReplicas: 1 },
|
||||
}),
|
||||
),
|
||||
).toEqual({ label: "1/3", level: "warn" });
|
||||
expect(
|
||||
deriveNodeStatus(
|
||||
object("Pod", "failed", { status: { phase: "Failed" } }),
|
||||
),
|
||||
).toEqual({
|
||||
label: "failed",
|
||||
level: "bad",
|
||||
});
|
||||
});
|
||||
|
||||
test("renders tree connectors, kind colors, and ANSI status badges", () => {
|
||||
const output = renderNamespaceGraphs([
|
||||
{
|
||||
namespace: "demo",
|
||||
roots: [
|
||||
{
|
||||
id: "Deployment/web",
|
||||
status: { label: "1/1", level: "good" },
|
||||
children: [
|
||||
{
|
||||
id: "Pod/web-abc",
|
||||
status: { label: "pending", level: "warn" },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(output).toContain("demo\n└─ \u001b[34mDeployment/web\u001b[0m");
|
||||
expect(output).toContain("\u001b[42m\u001b[30m 1/1 \u001b[0m");
|
||||
expect(output).toContain(" └─ \u001b[32mPod/web-abc\u001b[0m");
|
||||
expect(output).toContain("\u001b[43m\u001b[30m pending \u001b[0m");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { V1PodTemplateSpec, V1ReplicaSet } from "@kubernetes/client-node";
|
||||
import { stripPodTemplateHash, templateMatches } from "../../lib/rollback";
|
||||
|
||||
const template = (labels: Record<string, string> = {}): V1PodTemplateSpec => ({
|
||||
metadata: { name: "pod", labels },
|
||||
spec: {
|
||||
containers: [
|
||||
{ name: "app", image: "registry/kuber/demo:latest@sha256:aaaa" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
describe("rollback pod template handling", () => {
|
||||
test("strips the generated pod-template-hash label", () => {
|
||||
const result = stripPodTemplateHash(
|
||||
template({ app: "web", "pod-template-hash": "abc123" }),
|
||||
);
|
||||
expect(result.metadata?.labels).toEqual({ app: "web" });
|
||||
});
|
||||
|
||||
test("clears labels entirely when only the hash is present", () => {
|
||||
const result = stripPodTemplateHash(
|
||||
template({ "pod-template-hash": "abc123" }),
|
||||
);
|
||||
expect(result.metadata?.labels).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns a shallow copy without mutating the input", () => {
|
||||
const input = template({ app: "web" });
|
||||
const result = stripPodTemplateHash(input);
|
||||
expect(result).not.toBe(input);
|
||||
expect(input.metadata?.labels).toEqual({ app: "web" });
|
||||
});
|
||||
|
||||
test("treats templates as equal when they differ only by hash", () => {
|
||||
expect(
|
||||
templateMatches(
|
||||
template({ app: "web", "pod-template-hash": "one" }),
|
||||
template({ app: "web", "pod-template-hash": "two" }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("treats templates as different when other fields differ", () => {
|
||||
const a = template({ app: "web" });
|
||||
const b = template({ app: "worker" });
|
||||
expect(templateMatches(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
test("matches undefined templates", () => {
|
||||
expect(templateMatches(undefined, undefined)).toBe(true);
|
||||
expect(templateMatches(template(), undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user