Files
kuber/command/up.ts
T
dmgnr 6c8c8e0c1b feat: implement rollback command for deployments
- Added a new `rollback` command to roll back deployments to their previous release.
- Introduced `findRollbackCandidates` and `rollbackDeployment` functions to handle rollback logic.
- Updated CLI command definitions to include the new `rollback` command with an alias.
- Enhanced deployment conversion to use resolved images for buildable services.
- Introduced a new `graph` module to visualize Kubernetes resources and their relationships.
- Added tests for the new rollback functionality and graph rendering.
- Updated package version to 1.3.0.
2026-08-30 23:47:38 +07:00

246 lines
7.8 KiB
TypeScript

import { defineCommand } from "citty";
import { Listr } from "listr2";
import type { ComposeSpecification } from "../schema/docker.d";
import type { KuberResource } from "../types";
import { ctx } from "../lib/context";
import {
assertManagedNamespace,
applyResource,
deleteResource,
getStaleResources,
sortResources,
} from "../lib/apply";
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 { waitForDeploymentRollout } from "../lib/shared";
type UpContext = {
compose?: ComposeSpecification;
buildImages?: Record<string, string>;
serviceEnv?: Record<string, Record<string, string>>;
resources?: KubernetesResource[];
staleResources?: KubernetesResource[];
};
export function mergeServiceEnv(
current: Record<string, Record<string, string>> | undefined,
next: Record<string, Record<string, string>>,
): Record<string, Record<string, string>> {
const merged = { ...current };
for (const [service, environment] of Object.entries(next)) {
merged[service] = { ...merged[service], ...environment };
}
return merged;
}
export function getDeploymentNames(resources: KubernetesResource[]): string[] {
return resources
.filter((resource) => resource.kind === "Deployment")
.map((resource) => resource.metadata?.name)
.filter((name): name is string => Boolean(name));
}
export async function runUp(build: boolean) {
const { project, compose, cwd, config, hookContext: getHookContext } = ctx();
await assertManagedNamespace(project);
const taskCtx = await new Listr<UpContext>(
[
{
title: "Read compose",
task: async (taskCtx, task) => {
taskCtx.compose = await compose();
if (build) {
await config.preBuild?.(taskCtx.compose, await getHookContext());
}
task.output = `${Object.keys(taskCtx.compose.services ?? {}).length} services`;
},
},
{
title: "Build images",
rendererOptions: {
outputBar: 10,
persistentOutput: true,
},
enabled: async () =>
build &&
Object.values((await compose()).services ?? {}).some(
(service) => service.build,
),
task: async (taskCtx, task) => {
const result = await buildServices(
project,
taskCtx.compose!,
cwd,
{
progress: (message) => {
task.output = message;
},
stream: task.stdout(),
},
config,
);
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 () =>
getComposePostgresClaims(await compose()).length > 0,
task: async (taskCtx, task) => {
taskCtx.serviceEnv = mergeServiceEnv(
taskCtx.serviceEnv,
await reconcilePostgresClaims(project, taskCtx.compose!),
);
task.output = `${Object.keys(taskCtx.serviceEnv).length} service${Object.keys(taskCtx.serviceEnv).length === 1 ? "" : "s"}`;
},
},
{
title: "Reconcile S3 storage",
enabled: async () => getComposeS3Claims(await compose()).length > 0,
task: async (taskCtx, task) => {
taskCtx.serviceEnv = mergeServiceEnv(
taskCtx.serviceEnv,
await reconcileS3Claims(project, taskCtx.compose!),
);
task.output = `${Object.keys(taskCtx.serviceEnv).length} service${Object.keys(taskCtx.serviceEnv).length === 1 ? "" : "s"}`;
},
},
{
title: "Render manifests",
task: async (taskCtx, task) => {
taskCtx.resources = await composeToKubernetes(
project,
taskCtx.compose!,
cwd,
taskCtx.serviceEnv,
taskCtx.buildImages,
);
await config.postRender?.(
taskCtx.resources as KuberResource[],
await getHookContext(),
);
task.output = `${taskCtx.resources.length} resources`;
},
},
{
title: "Plan reconciliation",
task: async (taskCtx, task) => {
taskCtx.staleResources = (await getStaleResources(
project,
taskCtx.resources!,
)) as KubernetesResource[];
task.output = `${taskCtx.staleResources.length} stale resource${taskCtx.staleResources.length === 1 ? "" : "s"}`;
},
},
{
title: "Apply resources",
task: (taskCtx, task) => {
const resources = sortResources(taskCtx.resources!);
task.output = `${resources.length} resources queued`;
return task.newListr(
resources.map((resource) => ({
title: `${resource.kind} ${resource.metadata?.name}`,
task: () => applyResource(resource),
})),
{ concurrent: false, exitOnError: true },
);
},
},
{
title: "Run post-apply hook",
enabled: () => Boolean(config.postApply),
task: async (taskCtx) => {
await config.postApply?.(
taskCtx.resources! as KuberResource[],
await getHookContext(),
);
},
},
],
{ rendererOptions: { collapseErrors: false } },
).run();
const deployments = getDeploymentNames(taskCtx.resources!);
if (deployments.length > 0) {
await new Listr([
{
title: "Wait for rollout",
task: (_taskCtx, task) => {
task.output = `${deployments.length} deployments queued`;
return task.newListr(
deployments.map((name) => ({
title: `Deployment ${name}`,
task: () =>
waitForDeploymentRollout(name, config.rolloutTimeoutMs),
})),
{ concurrent: false, exitOnError: true },
);
},
},
]).run();
}
if (taskCtx.staleResources && taskCtx.staleResources.length > 0) {
const resources = sortResources(taskCtx.staleResources).reverse();
await new Listr([
{
title: "Delete stale resources",
task: (_taskCtx, task) => {
task.output = `${resources.length} resources queued`;
return task.newListr(
resources.map((resource) => ({
title: `${resource.kind} ${resource.metadata?.name}`,
task: () => deleteResource(resource),
})),
{ concurrent: false, exitOnError: true },
);
},
},
]).run();
}
}
export const up = defineCommand({
meta: {
name: "up",
description: "Create and start deployments",
},
args: {
build: {
type: "boolean",
default: true,
alias: "b",
description: "Build images before starting deployments",
negativeDescription: "Don't build an image, even if it's policy",
},
},
async run({ args }) {
await runUp(args.build);
},
});