- 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.
499 lines
16 KiB
TypeScript
499 lines
16 KiB
TypeScript
import type { KubernetesObject } from "@kubernetes/client-node";
|
|
import { objectApi } from "./k8s";
|
|
|
|
type GraphObject = KubernetesObject & {
|
|
spec?: Record<string, unknown>;
|
|
status?: Record<string, unknown>;
|
|
};
|
|
|
|
type Edge = {
|
|
from: string;
|
|
to: string;
|
|
type: "owner" | "hpa" | "service" | "ingress" | "volume";
|
|
};
|
|
|
|
export type NodeStatus = {
|
|
label: string;
|
|
level: "good" | "warn" | "bad";
|
|
};
|
|
|
|
export type GraphNode = {
|
|
id: string;
|
|
status: NodeStatus;
|
|
children: GraphNode[];
|
|
};
|
|
|
|
export type NamespaceGraph = {
|
|
namespace: string;
|
|
roots: GraphNode[];
|
|
};
|
|
|
|
const resources = [
|
|
["apps/v1", "Deployment"],
|
|
["apps/v1", "StatefulSet"],
|
|
["apps/v1", "DaemonSet"],
|
|
["apps/v1", "ReplicaSet"],
|
|
["v1", "Pod"],
|
|
["v1", "Service"],
|
|
["networking.k8s.io/v1", "Ingress"],
|
|
["autoscaling/v2", "HorizontalPodAutoscaler"],
|
|
["v1", "PersistentVolumeClaim"],
|
|
["v1", "ConfigMap"],
|
|
["v1", "Secret"],
|
|
["batch/v1", "Job"],
|
|
["batch/v1", "CronJob"],
|
|
["policy/v1", "PodDisruptionBudget"],
|
|
] as const;
|
|
|
|
const ansi = {
|
|
reset: "\u001b[0m",
|
|
fgBlack: "\u001b[30m",
|
|
fgBlue: "\u001b[34m",
|
|
fgPurple: "\u001b[35m",
|
|
fgYellow: "\u001b[33m",
|
|
fgGreen: "\u001b[32m",
|
|
bgGreen: "\u001b[42m",
|
|
bgYellow: "\u001b[43m",
|
|
bgRed: "\u001b[41m",
|
|
};
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function getKind(object: GraphObject): string {
|
|
return object.kind ?? "Unknown";
|
|
}
|
|
|
|
function getName(object: GraphObject): string {
|
|
return object.metadata?.name ?? "unknown";
|
|
}
|
|
|
|
function getId(object: GraphObject): string {
|
|
return `${getKind(object)}/${getName(object)}`;
|
|
}
|
|
|
|
function getNamespace(object: GraphObject): string {
|
|
return object.metadata?.namespace ?? "default";
|
|
}
|
|
|
|
function number(value: unknown): number | undefined {
|
|
return typeof value === "number" ? value : undefined;
|
|
}
|
|
|
|
function hasCondition(
|
|
object: GraphObject,
|
|
type: string,
|
|
status: string,
|
|
): boolean {
|
|
const conditions = Array.isArray(object.status?.conditions)
|
|
? object.status.conditions
|
|
: [];
|
|
return conditions.some(
|
|
(condition) =>
|
|
isRecord(condition) &&
|
|
condition.type === type &&
|
|
condition.status === status,
|
|
);
|
|
}
|
|
|
|
function stringRecord(value: unknown): Record<string, string> | undefined {
|
|
if (!isRecord(value)) return undefined;
|
|
const entries = Object.entries(value);
|
|
if (entries.some(([, item]) => typeof item !== "string")) return undefined;
|
|
return Object.fromEntries(entries) as Record<string, string>;
|
|
}
|
|
|
|
function matchesSelector(
|
|
selector: Record<string, string> | undefined,
|
|
labels: Record<string, string> | undefined,
|
|
): boolean {
|
|
const entries = Object.entries(selector ?? {});
|
|
return (
|
|
entries.length > 0 &&
|
|
entries.every(([key, value]) => labels?.[key] === value)
|
|
);
|
|
}
|
|
|
|
function priority(type: Edge["type"]): number {
|
|
return { owner: 5, hpa: 4, ingress: 3, service: 2, volume: 1 }[type];
|
|
}
|
|
|
|
function pushEdge(edges: Edge[], seen: Set<string>, edge: Edge): void {
|
|
const key = `${edge.from}\0${edge.to}\0${edge.type}`;
|
|
if (seen.has(key)) return;
|
|
seen.add(key);
|
|
edges.push(edge);
|
|
}
|
|
|
|
function collectEdges(items: GraphObject[]): Edge[] {
|
|
const edges: Edge[] = [];
|
|
const seen = new Set<string>();
|
|
const pods = items.filter((item) => getKind(item) === "Pod");
|
|
|
|
for (const child of items) {
|
|
for (const owner of child.metadata?.ownerReferences ?? []) {
|
|
if (owner.kind && owner.name) {
|
|
pushEdge(edges, seen, {
|
|
from: `${owner.kind}/${owner.name}`,
|
|
to: getId(child),
|
|
type: "owner",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const hpa of items.filter(
|
|
(item) => getKind(item) === "HorizontalPodAutoscaler",
|
|
)) {
|
|
const target = isRecord(hpa.spec?.scaleTargetRef)
|
|
? hpa.spec.scaleTargetRef
|
|
: undefined;
|
|
if (typeof target?.kind === "string" && typeof target.name === "string") {
|
|
pushEdge(edges, seen, {
|
|
from: getId(hpa),
|
|
to: `${target.kind}/${target.name}`,
|
|
type: "hpa",
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const service of items.filter((item) => getKind(item) === "Service")) {
|
|
const selector = stringRecord(service.spec?.selector);
|
|
for (const pod of pods) {
|
|
if (matchesSelector(selector, pod.metadata?.labels)) {
|
|
pushEdge(edges, seen, {
|
|
from: getId(service),
|
|
to: getId(pod),
|
|
type: "service",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const ingress of items.filter((item) => getKind(item) === "Ingress")) {
|
|
const serviceNames = new Set<string>();
|
|
const defaultBackend = isRecord(ingress.spec?.defaultBackend)
|
|
? ingress.spec.defaultBackend
|
|
: undefined;
|
|
const defaultService = isRecord(defaultBackend?.service)
|
|
? defaultBackend.service
|
|
: undefined;
|
|
if (typeof defaultService?.name === "string")
|
|
serviceNames.add(defaultService.name);
|
|
|
|
const rules = Array.isArray(ingress.spec?.rules) ? ingress.spec.rules : [];
|
|
for (const rule of rules) {
|
|
if (!isRecord(rule) || !isRecord(rule.http)) continue;
|
|
const paths = Array.isArray(rule.http.paths) ? rule.http.paths : [];
|
|
for (const path of paths) {
|
|
if (!isRecord(path) || !isRecord(path.backend)) continue;
|
|
const service = isRecord(path.backend.service)
|
|
? path.backend.service
|
|
: undefined;
|
|
if (typeof service?.name === "string") serviceNames.add(service.name);
|
|
}
|
|
}
|
|
for (const name of serviceNames) {
|
|
pushEdge(edges, seen, {
|
|
from: getId(ingress),
|
|
to: `Service/${name}`,
|
|
type: "ingress",
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const pod of pods) {
|
|
const volumes = Array.isArray(pod.spec?.volumes) ? pod.spec.volumes : [];
|
|
for (const volume of volumes) {
|
|
if (!isRecord(volume)) continue;
|
|
const pvc = isRecord(volume.persistentVolumeClaim)
|
|
? volume.persistentVolumeClaim
|
|
: undefined;
|
|
const configMap = isRecord(volume.configMap)
|
|
? volume.configMap
|
|
: undefined;
|
|
const secret = isRecord(volume.secret) ? volume.secret : undefined;
|
|
const targets = [
|
|
["PersistentVolumeClaim", pvc?.claimName],
|
|
["ConfigMap", configMap?.name],
|
|
["Secret", secret?.secretName],
|
|
] as const;
|
|
for (const [kind, name] of targets) {
|
|
if (typeof name === "string") {
|
|
pushEdge(edges, seen, {
|
|
from: getId(pod),
|
|
to: `${kind}/${name}`,
|
|
type: "volume",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return edges;
|
|
}
|
|
|
|
export function deriveNodeStatus(
|
|
object: GraphObject | undefined,
|
|
serviceTargetCount = 0,
|
|
): NodeStatus {
|
|
if (!object) return { label: "warn", level: "warn" };
|
|
const status = object.status;
|
|
|
|
switch (getKind(object)) {
|
|
case "Pod": {
|
|
const phase =
|
|
typeof status?.phase === "string" ? status.phase : "Unknown";
|
|
if (phase === "Running" && hasCondition(object, "Ready", "True"))
|
|
return { label: "ready", level: "good" };
|
|
if (phase === "Pending") return { label: "pending", level: "warn" };
|
|
if (phase === "Succeeded") return { label: "done", level: "good" };
|
|
return { label: phase.toLowerCase(), level: "bad" };
|
|
}
|
|
case "Deployment":
|
|
case "ReplicaSet":
|
|
case "StatefulSet": {
|
|
const ready = number(status?.readyReplicas) ?? 0;
|
|
const desired = number(status?.replicas) ?? 0;
|
|
if (desired === 0) return { label: "idle", level: "warn" };
|
|
if (ready === desired)
|
|
return { label: `${ready}/${desired}`, level: "good" };
|
|
if (ready > 0) return { label: `${ready}/${desired}`, level: "warn" };
|
|
return { label: `${ready}/${desired}`, level: "bad" };
|
|
}
|
|
case "DaemonSet": {
|
|
const ready = number(status?.numberReady) ?? 0;
|
|
const desired = number(status?.desiredNumberScheduled) ?? 0;
|
|
if (desired === 0) return { label: "idle", level: "warn" };
|
|
if (ready === desired)
|
|
return { label: `${ready}/${desired}`, level: "good" };
|
|
if (ready > 0) return { label: `${ready}/${desired}`, level: "warn" };
|
|
return { label: `${ready}/${desired}`, level: "bad" };
|
|
}
|
|
case "Job":
|
|
if (hasCondition(object, "Complete", "True"))
|
|
return { label: "done", level: "good" };
|
|
if (hasCondition(object, "Failed", "True"))
|
|
return { label: "failed", level: "bad" };
|
|
return { label: "running", level: "warn" };
|
|
case "CronJob":
|
|
return object.spec?.suspend === true
|
|
? { label: "paused", level: "warn" }
|
|
: { label: "ok", level: "good" };
|
|
case "PersistentVolumeClaim": {
|
|
const phase =
|
|
typeof status?.phase === "string" ? status.phase : "Unknown";
|
|
if (phase === "Bound") return { label: "bound", level: "good" };
|
|
if (phase === "Pending") return { label: "pending", level: "warn" };
|
|
return { label: phase.toLowerCase(), level: "bad" };
|
|
}
|
|
case "Service":
|
|
return serviceTargetCount > 0
|
|
? { label: `${serviceTargetCount} ep`, level: "good" }
|
|
: { label: "no ep", level: "warn" };
|
|
case "Ingress":
|
|
return { label: "route", level: "good" };
|
|
case "HorizontalPodAutoscaler": {
|
|
const current = number(status?.currentReplicas);
|
|
return current === undefined
|
|
? { label: "warn", level: "warn" }
|
|
: { label: `${current} cur`, level: "good" };
|
|
}
|
|
case "PodDisruptionBudget": {
|
|
const healthy = number(status?.currentHealthy) ?? 0;
|
|
const desired = number(status?.desiredHealthy) ?? 0;
|
|
if (healthy >= desired)
|
|
return { label: `${healthy}/${desired}`, level: "good" };
|
|
if (healthy > 0) return { label: `${healthy}/${desired}`, level: "warn" };
|
|
return { label: `${healthy}/${desired}`, level: "bad" };
|
|
}
|
|
case "ConfigMap":
|
|
case "Secret":
|
|
return { label: "ok", level: "good" };
|
|
default:
|
|
return { label: "ok", level: "warn" };
|
|
}
|
|
}
|
|
|
|
function isIdle(object: GraphObject | undefined): boolean {
|
|
return (
|
|
(getKind(object ?? {}) === "Deployment" ||
|
|
getKind(object ?? {}) === "ReplicaSet") &&
|
|
(number(object?.status?.replicas) ?? 0) === 0 &&
|
|
(number(object?.status?.readyReplicas) ?? 0) === 0
|
|
);
|
|
}
|
|
|
|
export function buildNamespaceGraphs(
|
|
items: GraphObject[],
|
|
options: { includeIdle?: boolean } = {},
|
|
): NamespaceGraph[] {
|
|
const byNamespace = new Map<string, GraphObject[]>();
|
|
for (const item of items) {
|
|
const bucket = byNamespace.get(getNamespace(item)) ?? [];
|
|
bucket.push(item);
|
|
byNamespace.set(getNamespace(item), bucket);
|
|
}
|
|
|
|
return [...byNamespace.entries()]
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([namespace, namespaceItems]) => {
|
|
const objectById = new Map(
|
|
namespaceItems.map((item) => [getId(item), item]),
|
|
);
|
|
const edges = collectEdges(namespaceItems).sort((left, right) =>
|
|
`${left.from}->${left.to}`.localeCompare(`${right.from}->${right.to}`),
|
|
);
|
|
const allNodes = new Set(namespaceItems.map(getId));
|
|
const bestIncoming = new Map<string, Edge>();
|
|
const serviceTargets = new Map<string, Set<string>>();
|
|
|
|
for (const edge of edges) {
|
|
if (!objectById.has(edge.from) || !objectById.has(edge.to)) continue;
|
|
if (edge.type === "service") {
|
|
const targets = serviceTargets.get(edge.from) ?? new Set<string>();
|
|
targets.add(edge.to);
|
|
serviceTargets.set(edge.from, targets);
|
|
}
|
|
const current = bestIncoming.get(edge.to);
|
|
if (!current || priority(edge.type) > priority(current.type))
|
|
bestIncoming.set(edge.to, edge);
|
|
}
|
|
|
|
const children = new Map<string, string[]>();
|
|
for (const edge of bestIncoming.values()) {
|
|
const bucket = children.get(edge.from) ?? [];
|
|
bucket.push(edge.to);
|
|
children.set(edge.from, bucket);
|
|
}
|
|
|
|
const hidden = new Set<string>();
|
|
const hideSubtree = (id: string): void => {
|
|
if (hidden.has(id)) return;
|
|
hidden.add(id);
|
|
for (const child of children.get(id) ?? []) hideSubtree(child);
|
|
};
|
|
|
|
if (!options.includeIdle) {
|
|
for (const item of namespaceItems) {
|
|
if (getKind(item) === "Deployment" && isIdle(item))
|
|
hideSubtree(getId(item));
|
|
}
|
|
}
|
|
|
|
for (const [parent, childIds] of children) {
|
|
if (getKind(objectById.get(parent) ?? {}) !== "Deployment") continue;
|
|
const replicaSets = childIds.filter(
|
|
(id) => getKind(objectById.get(id) ?? {}) === "ReplicaSet",
|
|
);
|
|
if (!replicaSets.some((id) => !isIdle(objectById.get(id)))) continue;
|
|
for (const id of replicaSets.filter((id) => isIdle(objectById.get(id))))
|
|
hideSubtree(id);
|
|
children.set(
|
|
parent,
|
|
childIds.filter((id) => !hidden.has(id)),
|
|
);
|
|
}
|
|
|
|
const buildNode = (id: string): GraphNode => ({
|
|
id,
|
|
status: deriveNodeStatus(
|
|
objectById.get(id),
|
|
serviceTargets.get(id)?.size,
|
|
),
|
|
children: [...new Set(children.get(id) ?? [])]
|
|
.filter((child) => !hidden.has(child))
|
|
.sort((left, right) => left.localeCompare(right))
|
|
.map(buildNode),
|
|
});
|
|
const printed = new Set<string>();
|
|
const roots: GraphNode[] = [];
|
|
const addRoot = (id: string): void => {
|
|
if (hidden.has(id) || printed.has(id)) return;
|
|
const root = buildNode(id);
|
|
const mark = (node: GraphNode): void => {
|
|
printed.add(node.id);
|
|
node.children.forEach(mark);
|
|
};
|
|
mark(root);
|
|
roots.push(root);
|
|
};
|
|
|
|
[...allNodes]
|
|
.filter((id) => !bestIncoming.has(id))
|
|
.sort((left, right) => left.localeCompare(right))
|
|
.forEach(addRoot);
|
|
[...allNodes]
|
|
.sort((left, right) => left.localeCompare(right))
|
|
.forEach(addRoot);
|
|
return { namespace, roots };
|
|
});
|
|
}
|
|
|
|
export async function fetchNamespaceObjects(
|
|
namespace: string,
|
|
): Promise<GraphObject[]> {
|
|
const lists = await Promise.all(
|
|
resources.map(async ([apiVersion, kind]) => {
|
|
const list = await objectApi.list(apiVersion, kind, namespace);
|
|
return list.items.map((item) => ({
|
|
...item,
|
|
apiVersion: item.apiVersion ?? apiVersion,
|
|
kind: item.kind ?? kind,
|
|
metadata: {
|
|
...item.metadata,
|
|
namespace: item.metadata?.namespace ?? namespace,
|
|
},
|
|
})) as GraphObject[];
|
|
}),
|
|
);
|
|
return lists.flat();
|
|
}
|
|
|
|
function badge(status: NodeStatus): string {
|
|
const background =
|
|
status.level === "good"
|
|
? ansi.bgGreen
|
|
: status.level === "bad"
|
|
? ansi.bgRed
|
|
: ansi.bgYellow;
|
|
return `${background}${ansi.fgBlack} ${status.label} ${ansi.reset}`;
|
|
}
|
|
|
|
function colorize(id: string): string {
|
|
const kind = id.split("/", 1)[0];
|
|
const color =
|
|
kind === "Deployment"
|
|
? ansi.fgBlue
|
|
: kind === "Service"
|
|
? ansi.fgPurple
|
|
: kind === "Ingress"
|
|
? ansi.fgYellow
|
|
: kind === "Pod"
|
|
? ansi.fgGreen
|
|
: undefined;
|
|
return color ? `${color}${id}${ansi.reset}` : id;
|
|
}
|
|
|
|
export function renderNamespaceGraphs(graphs: NamespaceGraph[]): string {
|
|
const lines: string[] = [];
|
|
const renderNode = (node: GraphNode, prefix: string, last: boolean): void => {
|
|
lines.push(
|
|
`${prefix}${last ? "└─ " : "├─ "}${colorize(node.id)} ${badge(node.status)}`,
|
|
);
|
|
const childPrefix = prefix + (last ? " " : "│ ");
|
|
node.children.forEach((child, index) => {
|
|
renderNode(child, childPrefix, index === node.children.length - 1);
|
|
});
|
|
};
|
|
for (const graph of graphs) {
|
|
lines.push(graph.namespace);
|
|
graph.roots.forEach((root, index) => {
|
|
renderNode(root, "", index === graph.roots.length - 1);
|
|
});
|
|
lines.push("");
|
|
}
|
|
return lines.join("\n");
|
|
}
|