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:
+87
-22
@@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user