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:
2026-08-30 23:47:38 +07:00 Unverified
parent 170d9ea081
commit 6c8c8e0c1b
19 changed files with 1159 additions and 81 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ export const exportCommand = defineCommand({
project,
await compose(),
cwd,
config.registry,
config,
);
await config.postRender?.(
resources as KuberResource[],
+3 -1
View File
@@ -7,6 +7,7 @@ import { exportCommand } from "./export";
import { logs } from "./logs";
import { ps } from "./ps";
import { restart } from "./restart";
import { rollback } from "./rollback";
import { s3 } from "./s3";
import { start } from "./start";
import { stop } from "./stop";
@@ -15,7 +16,7 @@ import { up } from "./up";
export const main = defineCommand({
meta: {
name: "kuber",
version: "1.2.2",
version: "1.3.0",
description: "Docker Compose -> K8s translation layer",
},
args: {
@@ -32,6 +33,7 @@ export const main = defineCommand({
logs,
ps,
restart,
rollback,
s3,
start,
stop,
+9 -9
View File
@@ -1,6 +1,10 @@
import { defineCommand } from "citty";
import { listDeployments } from "../lib/shared";
import { toTable } from "../lib/format";
import {
buildNamespaceGraphs,
fetchNamespaceObjects,
renderNamespaceGraphs,
} from "../lib/graph";
import { getProject } from "../lib/shared";
export const ps = defineCommand({
meta: {
@@ -16,14 +20,10 @@ export const ps = defineCommand({
},
},
async run({ args: { all } }) {
const objects = await fetchNamespaceObjects(getProject());
console.log(
toTable(
(await listDeployments())
.filter((d) => all || d.status?.replicas)
.map((d) => ({
name: d.metadata?.name,
status: `${d.status?.availableReplicas}/${d.status?.replicas}`,
})),
renderNamespaceGraphs(
buildNamespaceGraphs(objects, { includeIdle: all }),
),
);
},
+67
View File
@@ -0,0 +1,67 @@
import { defineCommand } from "citty";
import { Listr } from "listr2";
import { assertManagedNamespace } from "../lib/apply";
import { ctx } from "../lib/context";
import { planRollback, rollbackDeployment } from "../lib/rollback";
import { waitForDeploymentRollout } from "../lib/shared";
export const rollback = defineCommand({
meta: {
name: "rollback",
description: "Roll deployments back to the previous release",
alias: "fuck",
},
args: {
deployment: {
type: "positional",
description: "Deployment name to roll back (defaults to all managed)",
required: false,
},
},
async run({ args }) {
const { project, config } = ctx();
await assertManagedNamespace(project);
const names = args.deployment ? [args.deployment] : undefined;
const candidates = await planRollback(names);
if (candidates.length === 0) {
if (names) {
console.log(`${names[0]} has no previous release to roll back to`);
} else {
console.log("No deployments have a previous release to roll back to");
}
return;
}
await new Listr([
{
title: "Roll back deployments",
task: (taskCtx, task) => {
task.output = `${candidates.length} deployment${candidates.length === 1 ? "" : "s"} queued`;
return task.newListr(
candidates.map((candidate) => ({
title: `Deployment ${candidate.name}`,
task: () => rollbackDeployment(candidate),
})),
{ concurrent: false, exitOnError: true },
);
},
},
{
title: "Wait for rollout",
task: (taskCtx, task) => {
task.output = `${candidates.length} deployment${candidates.length === 1 ? "" : "s"} queued`;
return task.newListr(
candidates.map((candidate) => ({
title: `Deployment ${candidate.name}`,
task: () =>
waitForDeploymentRollout(candidate.name, config.rolloutTimeoutMs),
})),
{ concurrent: false, exitOnError: true },
);
},
},
]).run();
},
});
+21 -24
View File
@@ -10,18 +10,18 @@ import {
getStaleResources,
sortResources,
} from "../lib/apply";
import { buildServices } from "../lib/build";
import { buildServices, resolveBuildImages } from "../lib/build";
import { composeToKubernetes, type KubernetesResource } from "../lib/convert";
import {
getComposePostgresClaims,
reconcilePostgresClaims,
} from "../lib/database";
import { getComposeS3Claims, reconcileS3Claims } from "../lib/storage";
import { restartDeployment, waitForDeploymentRollout } from "../lib/shared";
import { waitForDeploymentRollout } from "../lib/shared";
type UpContext = {
compose?: ComposeSpecification;
changedDeployments?: string[];
buildImages?: Record<string, string>;
serviceEnv?: Record<string, Record<string, string>>;
resources?: KubernetesResource[];
staleResources?: KubernetesResource[];
@@ -85,11 +85,27 @@ export async function runUp(build: boolean) {
},
config,
);
taskCtx.changedDeployments = result.changed;
taskCtx.buildImages = result.images;
await config.postBuild?.(result, await getHookContext());
task.output = `Built ${result.built.length} image${result.built.length === 1 ? "" : "s"}, ${result.changed.length} changed`;
},
},
{
title: "Resolve images",
enabled: async () =>
!build &&
Object.values((await compose()).services ?? {}).some(
(service) => service.build,
),
task: async (taskCtx, task) => {
taskCtx.buildImages = await resolveBuildImages(
project,
taskCtx.compose!,
config,
);
task.output = `${Object.keys(taskCtx.buildImages).length} image${Object.keys(taskCtx.buildImages).length === 1 ? "" : "s"}`;
},
},
{
title: "Reconcile databases",
enabled: async () =>
@@ -121,7 +137,7 @@ export async function runUp(build: boolean) {
taskCtx.compose!,
cwd,
taskCtx.serviceEnv,
config.registry,
taskCtx.buildImages,
);
await config.postRender?.(
taskCtx.resources as KuberResource[],
@@ -169,25 +185,6 @@ export async function runUp(build: boolean) {
{ rendererOptions: { collapseErrors: false } },
).run();
const changedDeployments = taskCtx.changedDeployments ?? [];
if (changedDeployments.length > 0) {
await new Listr([
{
title: "Restart changed deployments",
task: (_taskCtx, task) => {
task.output = `${changedDeployments.length} deployments queued`;
return task.newListr(
changedDeployments.map((name) => ({
title: `Deployment ${name}`,
task: () => restartDeployment(name),
})),
{ concurrent: false, exitOnError: true },
);
},
},
]).run();
}
const deployments = getDeploymentNames(taskCtx.resources!);
if (deployments.length > 0) {
await new Listr([