Files
kuber/lib/rollback.ts
T
dmgnr 6c8c8e0c1b 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.
2026-08-30 23:47:38 +07:00

171 lines
4.8 KiB
TypeScript

import type {
V1Deployment,
V1PodTemplateSpec,
V1ReplicaSet,
} from "@kubernetes/client-node";
import { LABELS } from "../const";
import { ctx } from "./context";
import { apps } from "./k8s";
import { listManagedDeployments, waitForDeploymentRollout } from "./shared";
const RevisionAnnotation = "deployment.kubernetes.io/revision";
const PodTemplateHashLabel = "pod-template-hash";
const ManagedBySelector = `app.kubernetes.io/managed-by=${LABELS["app.kubernetes.io/managed-by"]}`;
export type RollbackCandidate = {
name: string;
currentRevision: number;
previousRevision: number;
image: string;
};
function getRevision(rs: V1ReplicaSet): number | undefined {
const raw = rs.metadata?.annotations?.[RevisionAnnotation];
if (!raw) return undefined;
const revision = Number(raw);
return Number.isInteger(revision) && revision > 0 ? revision : undefined;
}
export function stripPodTemplateHash(template: V1PodTemplateSpec): V1PodTemplateSpec {
const labels = { ...template.metadata?.labels };
delete labels[PodTemplateHashLabel];
return {
...template,
metadata: {
...template.metadata,
labels: Object.keys(labels).length > 0 ? labels : undefined,
},
};
}
export function templateMatches(
template: V1PodTemplateSpec | undefined,
previous: V1PodTemplateSpec | undefined,
): boolean {
return (
JSON.stringify(stripPodTemplateHash(template ?? {})) ===
JSON.stringify(stripPodTemplateHash(previous ?? {}))
);
}
export async function listDeploymentReplicaSets(
deployment: V1Deployment,
): Promise<V1ReplicaSet[]> {
const name = deployment.metadata?.name;
const ownerUid = deployment.metadata?.uid;
if (!name || !ownerUid) return [];
const { items } = await apps.listNamespacedReplicaSet({
namespace: ctx().project,
labelSelector: ManagedBySelector,
});
return items.filter((rs) =>
rs.metadata?.ownerReferences?.some(
(ref) => ref.uid === ownerUid && ref.kind === "Deployment",
),
);
}
export async function findRollbackCandidates(
deployment: V1Deployment,
): Promise<RollbackCandidate | undefined> {
const name = deployment.metadata?.name;
if (!name) return undefined;
const replicaSets = await listDeploymentReplicaSets(deployment);
const revisions = replicaSets
.map((rs) => ({ rs, revision: getRevision(rs) }))
.filter(
(entry): entry is { rs: V1ReplicaSet; revision: number } =>
entry.revision !== undefined,
)
.sort((a, b) => b.revision - a.revision);
if (revisions.length < 2) return undefined;
const [current, ...rest] = revisions;
if (!current) return undefined;
const previous =
rest.find((entry) =>
!templateMatches(entry.rs.spec?.template, deployment.spec?.template),
) ?? rest[0];
if (!previous) return undefined;
const image = previous.rs.spec?.template?.spec?.containers?.[0]?.image;
if (!image) return undefined;
return {
name,
currentRevision: current.revision,
previousRevision: previous.revision,
image,
};
}
export async function planRollback(
names?: string[],
): Promise<RollbackCandidate[]> {
const deployments = await listManagedDeployments();
const targets = names
? deployments.filter((deployment) =>
names.includes(deployment.metadata?.name ?? ""),
)
: deployments;
if (names) {
const found = new Set(targets.map((deployment) => deployment.metadata?.name));
for (const name of names) {
if (!found.has(name)) {
throw new Error(`No managed deployment named ${name} in ${ctx().project}`);
}
}
}
const candidates: RollbackCandidate[] = [];
for (const deployment of targets) {
const candidate = await findRollbackCandidates(deployment);
if (candidate) candidates.push(candidate);
}
return candidates;
}
export async function rollbackDeployment(
candidate: RollbackCandidate,
): Promise<V1Deployment> {
const { name, previousRevision } = candidate;
const deployment = await apps.readNamespacedDeployment({
namespace: ctx().project,
name,
});
const replicaSets = await listDeploymentReplicaSets(deployment);
const previous = replicaSets.find(
(rs) => getRevision(rs) === previousRevision,
);
if (!previous?.spec?.template) {
throw new Error(
`Deployment ${name} has no ReplicaSet for revision ${previousRevision}`,
);
}
const template = stripPodTemplateHash(previous.spec.template);
return apps.patchNamespacedDeployment({
namespace: ctx().project,
name,
body: [
{
op: "replace",
path: "/spec/template",
value: template,
},
],
});
}
export async function rollbackAll(candidates: RollbackCandidate[], timeoutMs = 300000) {
for (const candidate of candidates) {
await rollbackDeployment(candidate);
}
for (const candidate of candidates) {
await waitForDeploymentRollout(candidate.name, timeoutMs);
}
}