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:
@@ -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
@@ -38,7 +38,7 @@ export const exportCommand = defineCommand({
|
||||
project,
|
||||
await compose(),
|
||||
cwd,
|
||||
config.registry,
|
||||
config,
|
||||
);
|
||||
await config.postRender?.(
|
||||
resources as KuberResource[],
|
||||
|
||||
+3
-1
@@ -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
@@ -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 }),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Vendored
+2
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user