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
+33 -4
View File
@@ -66,6 +66,8 @@ bun run index.ts exec app sh
bun run index.ts start
bun run index.ts stop
bun run index.ts restart
bun run index.ts rollback
bun run index.ts fuck app
bun run index.ts db ls
bun run index.ts s3 ls
bun run index.ts s3 creds app
@@ -93,13 +95,15 @@ For a permanent setup, write the generated script to a file and source it from y
## Commands
- `up`: build images if needed, render manifests, apply them, restart deployments whose image content changed, and wait for rollout
- `start`: same as `up` but skips image builds
- `up`: build images if needed, pin matching registry digests, render manifests, apply them, and wait for rollout
- `start`: same as `up` but resolves the currently published image digests instead of building
- `stop`: scale managed deployments to zero
- `restart`: roll out a restart across managed deployments
- `rollback` (alias `fuck`): roll one deployment back to its previous release, or all managed deployments when no name is given
- `down`: delete managed resources while keeping ingress, PVCs, managed databases, and managed S3 storage
- `down -f`: also delete ingress, PVCs, managed database and S3 resources, and the namespace
- `ps`: list deployments in the current project namespace
- `ps`: print an ANSI tree of the current project namespace, hiding stopped deployments by default
- `ps -a`: include stopped deployments and stale ReplicaSets in the tree
- `logs [deployment]`: print logs for one deployment or all managed deployments
- `logs -f [deployment]`: follow logs continuously
- `exec <deployment> <command...>`: execute a command inside a running deployment pod
@@ -366,7 +370,32 @@ Precedence:
## Building
Image builds and image-digest checks run through the selected SSH builder. If a pushed image has the same content fingerprint as the existing registry image, `up` does not restart that deployment.
Image builds run through the selected SSH builder. After a push, the registry's
manifest digest is captured and embedded into the rendered Deployment as an
immutable `:latest@sha256:...` reference, so a changed image naturally triggers
a rollout (there is no separate "restart changed deployments" step). `start` and
`export` look up the currently published digest without rebuilding, and fail if
a buildable service has no published image yet.
## Rollback
`kuber rollback [deployment]` (alias `fuck`) rewinds managed Deployments to the
previous release. ReplicaSets carry a `deployment.kubernetes.io/revision`
annotation, and rollback restores the complete pod template from the next-older
ReplicaSet via a JSON Patch, then waits for the rollout to complete. With no
argument every managed Deployment is rolled back; pass a deployment name to
target a single one.
Notes and limitations:
- Kubernetes only keeps its most recent ReplicaSets, so an older release may no
longer be reachable after enough successful rollouts/rollbacks.
- Rollback restores the pod template (including image and environment), not live
Secret, ConfigMap, PVC, database, or S3 state.
- A later `kuber up` or `kuber start` re-resolves `:latest` and returns the
Deployment to the current desired state anyway, so rollback is the right tool
for responding to a bad deploy, not for permanently pinning an old version.
- Rollback only considers Deployments managed by kuber (`app.kubernetes.io/managed-by=kuber`).
To build distributable binaries:
+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([
+86 -21
View File
@@ -54,6 +54,7 @@ type BuildReporter = {
export type BuildResult = {
built: string[];
changed: string[];
images: Record<string, string>;
};
type SpawnResult = {
@@ -155,7 +156,7 @@ function resolveBuildPlan(
return {
name,
image: `${getBuildRuntime().registry}/kuber/${project}-${name}:latest`,
image: getBuildImageName(project, name),
context: `${buildRoot}/${contextRelative === "" ? "." : contextRelative}`,
dockerfile: dockerfileRelative
? `${buildRoot}/${dockerfileRelative}`
@@ -289,9 +290,33 @@ function scp(localPath: string, remotePath: string) {
});
}
async function getRemoteImageDigest(
image: string,
): Promise<string | undefined> {
export function parseImageManifestDigest(output: string): string {
const manifest = JSON.parse(output) as { digest?: unknown };
if (
typeof manifest.digest !== "string" ||
!/^sha256:[a-f0-9]{64}$/.test(manifest.digest)
) {
throw new Error("Registry response did not contain a valid image digest");
}
return manifest.digest;
}
export function toPinnedImage(image: string, digest: string): string {
if (!/^sha256:[a-f0-9]{64}$/.test(digest)) {
throw new Error(`Invalid image digest ${digest}`);
}
return `${image}@${digest}`;
}
export function getBuildImageName(
project: string,
service: string,
registry = getBuildRuntime().registry,
): string {
return `${registry}/kuber/${project}-${service}:latest`;
}
async function inspectRemoteImageDigest(image: string): Promise<string> {
const inspectCommand = [
"docker",
"buildx",
@@ -299,17 +324,22 @@ async function getRemoteImageDigest(
"inspect",
image,
"--format",
"{{json .Image}}",
"{{json .Manifest}}",
]
.map(shellQuote)
.join(" ");
try {
const result = await runWithOutput(
ssh(`set -euo pipefail; ${inspectCommand} | sha256sum`),
ssh(`set -euo pipefail; ${inspectCommand}`),
);
const digest = /^([a-f0-9]{64})\s/.exec(result.stdout)?.[1];
return digest ? `sha256:${digest}` : undefined;
return parseImageManifestDigest(result.stdout);
}
async function tryInspectRemoteImageDigest(
image: string,
): Promise<string | undefined> {
try {
return await inspectRemoteImageDigest(image);
} catch {
return;
}
@@ -322,6 +352,45 @@ export function imageDigestChanged(
return !before || !after || before !== after;
}
function resolveBuildRuntime(options: BuildOptions): BuildRuntime {
return {
registry: options.registry ?? DEFAULT_REGISTRY,
builders: {
amd64: options.builders?.amd64 ?? DEFAULT_BUILDERS.amd64,
arm64: options.builders?.arm64 ?? DEFAULT_BUILDERS.arm64,
remoteRoot: options.builders?.remoteRoot ?? DEFAULT_BUILDERS.remoteRoot,
},
};
}
export async function resolveBuildImages(
project: string,
compose: ComposeSpecification,
options: BuildOptions = {},
): Promise<Record<string, string>> {
return buildRuntime.run(resolveBuildRuntime(options), () =>
withComposeArch(compose, async () => {
const images: Record<string, string> = {};
for (const [name, service] of Object.entries(compose.services ?? {})) {
if (!service.build) continue;
const image = getBuildImageName(project, name);
try {
images[name] = toPinnedImage(
image,
await inspectRemoteImageDigest(image),
);
} catch (error) {
throw new Error(
`Cannot resolve a published image for service ${name}. Run kuber up to build it.`,
{ cause: error },
);
}
}
return images;
}),
);
}
async function getRepoRoot(cwd: string): Promise<string> {
return Bun.$.cwd(cwd)`git rev-parse --show-toplevel`
.text()
@@ -595,21 +664,14 @@ export async function buildServices(
reporter?: BuildReporter,
options: BuildOptions = {},
): Promise<BuildResult> {
const runtime: BuildRuntime = {
registry: options.registry ?? DEFAULT_REGISTRY,
builders: {
amd64: options.builders?.amd64 ?? DEFAULT_BUILDERS.amd64,
arm64: options.builders?.arm64 ?? DEFAULT_BUILDERS.arm64,
remoteRoot: options.builders?.remoteRoot ?? DEFAULT_BUILDERS.remoteRoot,
},
};
const runtime = resolveBuildRuntime(options);
return buildRuntime.run(runtime, () =>
withComposeArch(compose, async () => {
if (
!Object.values(compose.services ?? {}).some((service) => service.build)
) {
return { built: [], changed: [] };
return { built: [], changed: [], images: {} };
}
const repoRoot = await getRepoRoot(cwd);
@@ -619,24 +681,27 @@ export async function buildServices(
: `${getBuildRuntime().builders.remoteRoot}/${basename(repoRoot)}`;
const plans = getBuildPlans(project, compose, cwd, repoRoot, buildRoot);
if (plans.length === 0) return { built: [], changed: [] };
if (plans.length === 0) return { built: [], changed: [], images: {} };
if (!localBuilder) {
await syncRemoteRepo(repoRoot, buildRoot, reporter);
}
const changed: string[] = [];
const images: Record<string, string> = {};
for (const plan of plans) {
const before = await getRemoteImageDigest(plan.image);
const before = await tryInspectRemoteImageDigest(plan.image);
if (localBuilder) await buildLocal(plan, reporter);
else await buildRemote(plan, reporter);
const after = await getRemoteImageDigest(plan.image);
const after = await inspectRemoteImageDigest(plan.image);
images[plan.name] = toPinnedImage(plan.image, after);
if (imageDigestChanged(before, after)) changed.push(plan.name);
}
return {
built: plans.map((plan) => plan.name),
changed,
images,
};
}),
);
+12 -8
View File
@@ -21,7 +21,6 @@ import type { ComposeSpecification } from "../schema/docker.d";
import type { Service } from "../schema/docker.d";
import { LABELS } from "../const";
import { getComposeArchPlacement } from "./arch";
import { DEFAULT_REGISTRY } from "./config";
import { isPostgresVolumeEntry } from "./database";
import { toEnvVars } from "./format";
import { deepMerge } from "./shared";
@@ -78,12 +77,17 @@ function toReplicaCount(service: Service): number {
}
function toDeploymentImage(
project: string,
name: string,
service: Service,
registry: string,
buildImages: Record<string, string>,
): string | undefined {
if (service.build) return `${registry}/kuber/${project}-${name}:latest`;
if (service.build) {
const image = buildImages[name];
if (!image) {
throw new Error(`Missing resolved build image for service ${name}`);
}
return image;
}
return service.image;
}
@@ -845,7 +849,7 @@ export function serviceToDeployment(
cwd = process.cwd(),
extraEnv: Record<string, string> = {},
volumes: ComposeVolumes = {},
registry = DEFAULT_REGISTRY,
buildImages: Record<string, string> = {},
): V1Deployment {
const mounts = toMounts(service, cwd, volumes);
const ports = toPorts(service);
@@ -888,7 +892,7 @@ export function serviceToDeployment(
name,
imagePullPolicy: "Always",
env: toEnvVars(service.environment),
image: toDeploymentImage(project, name, service, registry),
image: toDeploymentImage(name, service, buildImages),
command:
service.command instanceof Array
? service.command
@@ -1098,7 +1102,7 @@ export async function composeToKubernetes(
compose: ComposeSpecification,
cwd = process.cwd(),
serviceEnv: Record<string, Record<string, string>> = {},
registry = DEFAULT_REGISTRY,
buildImages: Record<string, string> = {},
): Promise<KubernetesResource[]> {
const resources = new Map<string, KubernetesResource>();
@@ -1141,7 +1145,7 @@ export async function composeToKubernetes(
cwd,
serviceEnv[name] ?? {},
compose.volumes,
registry,
buildImages,
);
const envSecret = envSecrets[0];
if (envSecret) {
+498
View File
@@ -0,0 +1,498 @@
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");
}
+4 -3
View File
@@ -2,13 +2,13 @@ import type { ComposeSpecification } from "../schema/docker.d";
import { composeToKubernetes, type KubernetesResource } from "./convert";
import { reconcilePostgresClaims } from "./database";
import { reconcileS3Claims } from "./storage";
import { DEFAULT_REGISTRY } from "./config";
import { resolveBuildImages, type BuildOptions } from "./build";
export async function renderResources(
project: string,
compose: ComposeSpecification,
cwd = process.cwd(),
registry = DEFAULT_REGISTRY,
options: BuildOptions = {},
): Promise<KubernetesResource[]> {
const postgresEnv = await reconcilePostgresClaims(project, compose);
const s3Env = await reconcileS3Claims(project, compose);
@@ -16,5 +16,6 @@ export async function renderResources(
for (const [service, environment] of Object.entries(s3Env)) {
serviceEnv[service] = { ...serviceEnv[service], ...environment };
}
return composeToKubernetes(project, compose, cwd, serviceEnv, registry);
const buildImages = await resolveBuildImages(project, compose, options);
return composeToKubernetes(project, compose, cwd, serviceEnv, buildImages);
}
+170
View File
@@ -0,0 +1,170 @@
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);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@dmgnr/kuber",
"version": "1.2.2",
"version": "1.3.0",
"description": "Docker Compose to Kubernetes translation layer",
"bin": {
"kuber": "dist/index.js"
+8 -3
View File
@@ -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",
+1 -1
View File
@@ -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", () => {
+2 -2
View File
@@ -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)}`,
);
});
});
+10 -2
View File
@@ -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", () => {
+175
View File
@@ -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");
});
});
+55
View File
@@ -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);
});
});
Vendored
+2
View File
@@ -14,6 +14,8 @@ export interface KuberHookContext {
export interface KuberBuildResult {
built: string[];
changed: string[];
/** Resolved immutable image references (`name -> registry/.../image:latest@sha256:...`) for buildable services. */
images: Record<string, string>;
}
export interface KuberResource {