diff --git a/README.md b/README.md index b4b34a0..2d326cd 100644 --- a/README.md +++ b/README.md @@ -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 `: 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: diff --git a/command/export.ts b/command/export.ts index 9ba7e94..92e0346 100644 --- a/command/export.ts +++ b/command/export.ts @@ -38,7 +38,7 @@ export const exportCommand = defineCommand({ project, await compose(), cwd, - config.registry, + config, ); await config.postRender?.( resources as KuberResource[], diff --git a/command/main.ts b/command/main.ts index 981848f..ceb27c8 100644 --- a/command/main.ts +++ b/command/main.ts @@ -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, diff --git a/command/ps.ts b/command/ps.ts index 7e5d0f1..01cd70d 100644 --- a/command/ps.ts +++ b/command/ps.ts @@ -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 }), ), ); }, diff --git a/command/rollback.ts b/command/rollback.ts new file mode 100644 index 0000000..589af07 --- /dev/null +++ b/command/rollback.ts @@ -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(); + }, +}); diff --git a/command/up.ts b/command/up.ts index 3cb9306..aeb4700 100644 --- a/command/up.ts +++ b/command/up.ts @@ -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; serviceEnv?: Record>; 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([ diff --git a/lib/build.ts b/lib/build.ts index ae5e134..e1dbf1c 100644 --- a/lib/build.ts +++ b/lib/build.ts @@ -54,6 +54,7 @@ type BuildReporter = { export type BuildResult = { built: string[]; changed: string[]; + images: Record; }; 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 { +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 { 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 { 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> { + return buildRuntime.run(resolveBuildRuntime(options), () => + withComposeArch(compose, async () => { + const images: Record = {}; + 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 { return Bun.$.cwd(cwd)`git rev-parse --show-toplevel` .text() @@ -595,21 +664,14 @@ export async function buildServices( reporter?: BuildReporter, options: BuildOptions = {}, ): Promise { - 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 = {}; 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, }; }), ); diff --git a/lib/convert.ts b/lib/convert.ts index 43e301d..3e20fee 100644 --- a/lib/convert.ts +++ b/lib/convert.ts @@ -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 | 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 = {}, volumes: ComposeVolumes = {}, - registry = DEFAULT_REGISTRY, + buildImages: Record = {}, ): 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> = {}, - registry = DEFAULT_REGISTRY, + buildImages: Record = {}, ): Promise { const resources = new Map(); @@ -1141,7 +1145,7 @@ export async function composeToKubernetes( cwd, serviceEnv[name] ?? {}, compose.volumes, - registry, + buildImages, ); const envSecret = envSecrets[0]; if (envSecret) { diff --git a/lib/graph.ts b/lib/graph.ts new file mode 100644 index 0000000..f1c8698 --- /dev/null +++ b/lib/graph.ts @@ -0,0 +1,498 @@ +import type { KubernetesObject } from "@kubernetes/client-node"; +import { objectApi } from "./k8s"; + +type GraphObject = KubernetesObject & { + spec?: Record; + status?: Record; +}; + +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 { + 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 | 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; +} + +function matchesSelector( + selector: Record | undefined, + labels: Record | 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, 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(); + 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(); + 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(); + 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(); + const serviceTargets = new Map>(); + + 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(); + 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(); + 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(); + 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(); + 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 { + 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"); +} diff --git a/lib/render.ts b/lib/render.ts index 8effa84..4629ef9 100644 --- a/lib/render.ts +++ b/lib/render.ts @@ -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 { 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); } diff --git a/lib/rollback.ts b/lib/rollback.ts new file mode 100644 index 0000000..34c527d --- /dev/null +++ b/lib/rollback.ts @@ -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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/package.json b/package.json index a017cd9..d862f79 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/tests/command/metadata.test.ts b/tests/command/metadata.test.ts index e1f0579..00cec13 100644 --- a/tests/command/metadata.test.ts +++ b/tests/command/metadata.test.ts @@ -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", diff --git a/tests/command/up.test.ts b/tests/command/up.test.ts index 931f9e0..51d3118 100644 --- a/tests/command/up.test.ts +++ b/tests/command/up.test.ts @@ -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", () => { diff --git a/tests/lib/config.test.ts b/tests/lib/config.test.ts index 4f80e5c..d1fbaf4 100644 --- a/tests/lib/config.test.ts +++ b/tests/lib/config.test.ts @@ -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)}`, ); }); }); diff --git a/tests/lib/convert-deployment.test.ts b/tests/lib/convert-deployment.test.ts index ab56413..c6a30a2 100644 --- a/tests/lib/convert-deployment.test.ts +++ b/tests/lib/convert-deployment.test.ts @@ -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", () => { diff --git a/tests/lib/graph.test.ts b/tests/lib/graph.test.ts new file mode 100644 index 0000000..46a8176 --- /dev/null +++ b/tests/lib/graph.test.ts @@ -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; + status?: Record; +}; + +const object = ( + kind: string, + name: string, + extra: Partial = {}, +): 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"); + }); +}); diff --git a/tests/lib/rollback.test.ts b/tests/lib/rollback.test.ts new file mode 100644 index 0000000..985fa77 --- /dev/null +++ b/tests/lib/rollback.test.ts @@ -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 = {}): 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); + }); +}); diff --git a/types.d.ts b/types.d.ts index ecf9220..6263b48 100644 --- a/types.d.ts +++ b/types.d.ts @@ -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; } export interface KuberResource {