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
+87 -22
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(" ");
const result = await runWithOutput(
ssh(`set -euo pipefail; ${inspectCommand}`),
);
return parseImageManifestDigest(result.stdout);
}
async function tryInspectRemoteImageDigest(
image: string,
): Promise<string | undefined> {
try {
const result = await runWithOutput(
ssh(`set -euo pipefail; ${inspectCommand} | sha256sum`),
);
const digest = /^([a-f0-9]{64})\s/.exec(result.stdout)?.[1];
return digest ? `sha256:${digest}` : undefined;
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,
};
}),
);