/** * Value-aware "rebase" of a container's env/labels onto a new image * during in-place update (#1226, #1256). * * Pure, dependency-free, unit-testable — no Docker, no node:http/fs — so * `bun test tests/unit/` can import it directly. * * Background: when Dockhand recreates a container on a new image it must * rebuild the create body from the OLD container's inspect. A container's * Config.Env / Config.Labels are FLATTENED at create time: image-baked * defaults and the user's runtime `-e`/`-l` overrides are indistinguishable. * Spreading them verbatim freezes the OLD image's defaults, so an image * that bumps a baked env (e.g. APP_VERSION) or an OCI label never takes * effect on update (#1226 / #1256). * * The recoverable signal is VALUE comparison against the OLD image: * container[K] == oldImage[K] -> image-baked, user never touched it -> adopt newImage[K] * container[K] != oldImage[K] -> user override -> keep container[K] * K only in container -> user-added -> keep container[K] * K only in newImage -> new image default -> add newImage[K] * * This is the #1135 merge corrected from key-membership to value-equality: * a user's override of a baked key (value differs) is preserved, while a * baked key the user never touched follows the new image. The caller must * fall back to verbatim when the old image can't be inspected. */ /** * The subset of an image's Config needed to rebase env/labels. Captured * from an image inspect BEFORE a new image is pulled (the old image may be * untagged/GC'd afterward and no longer inspectable). */ export interface ImageEnvLabels { Env?: string[]; Labels?: Record; /** The image's baked CMD / ENTRYPOINT, for the same value-aware rebase (#1371). */ Cmd?: string[] | null; Entrypoint?: string[] | null; } /** * Rebase a container's Cmd/Entrypoint onto the new image (#1371). Same flattening problem * as Env/Labels: a container's Config.Cmd is set at create time whether it came from the * image's CMD or a user `command:` override - spreading it verbatim onto a new image freezes * the OLD image's command, so a container can start with a command the NEW image no longer * provides (missing entrypoint script -> exit 127 -> restart loop). * * The recoverable signal is the same VALUE comparison: * container == oldImage -> came from the old image, user never overrode it -> adopt newImage * container != oldImage -> a real user/Compose override -> keep container * Cmd/Entrypoint are ordered arrays, so equality is order-sensitive (JSON compare). When the * old image value is unknown (null/undefined - caller couldn't inspect it) we KEEP the * container's value (verbatim fallback), exactly as the env/label rebase does. */ export function rebaseCommand( containerValue: string[] | null | undefined, oldImageValue: string[] | null | undefined, newImageValue: string[] | null | undefined ): { value: string[] | null | undefined; adopted: boolean } { // Old image not inspectable -> can't tell image-default from user override -> keep verbatim. if (oldImageValue == null) return { value: containerValue, adopted: false }; const eq = (a: string[] | null | undefined, b: string[] | null | undefined) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null); // User overrode it (differs from the old image's baked value) -> keep the override. if (!eq(containerValue, oldImageValue)) return { value: containerValue, adopted: false }; // Image-baked and untouched -> follow the new image. (Same value -> no-op, adopted:false.) if (eq(newImageValue, oldImageValue)) return { value: containerValue, adopted: false }; return { value: newImageValue, adopted: true }; } /** * Operational labels that must never be dropped by a rebase, even if a * future image happens to bake the same key. These are set by Docker * Compose / Dockhand and are load-bearing for stack association and * update tracking. */ function isProtectedLabel(key: string): boolean { return ( key.startsWith('com.docker.compose.') || key.startsWith('com.docker.stack.') || key.startsWith('dockhand.') || key.startsWith('dh.') ); } /** * Sentinel for a bare env entry (`KEY` with no `=`), which Docker treats * as "inherit this var from the daemon environment" — semantically * distinct from `KEY=`. We keep it distinct so such entries round-trip * unchanged instead of being normalized to an empty value. */ const INHERIT = Symbol('inherit-from-daemon'); type EnvValue = string | typeof INHERIT; /** Parse a Docker env list (`KEY=value` strings) into a Map (last wins). */ function parseEnv(entries: string[]): Map { const m = new Map(); for (const e of entries) { const i = e.indexOf('='); if (i === -1) { m.set(e, INHERIT); } else { m.set(e.slice(0, i), e.slice(i + 1)); } } return m; } /** Serialize one KEY/value pair back to a Docker env string. */ function toEnvString(key: string, value: EnvValue): string { return value === INHERIT ? key : `${key}=${value}`; } /** * Rebase a container's env onto the new image's defaults. * * Emits new-image defaults first (in new-image order), then any user * entries not already emitted (in container order). User values always * win over image defaults for the same key. */ export function rebaseEnvOntoImage( containerEnv: string[], oldImageEnv: string[], newImageEnv: string[] ): string[] { const container = parseEnv(containerEnv); const oldImage = parseEnv(oldImageEnv); const newImage = parseEnv(newImageEnv); // Recover the user layer: container entries that are NOT byte-identical // to an old-image default (differing value, or key absent from old image). const userLayer = new Map(); for (const [k, v] of container) { if (!oldImage.has(k) || oldImage.get(k) !== v) { userLayer.set(k, v); } } const out: string[] = []; const emitted = new Set(); // New image defaults as the base, unless the user overrode that key. for (const [k, v] of newImage) { if (userLayer.has(k)) { out.push(toEnvString(k, userLayer.get(k)!)); } else { out.push(toEnvString(k, v)); } emitted.add(k); } // User entries for keys the new image doesn't define (user-only / kept overrides). for (const [k, v] of userLayer) { if (!emitted.has(k)) { out.push(toEnvString(k, v)); emitted.add(k); } } return out; } /** Human-readable breakdown of what a rebase changed, for logging/UI. */ export interface RebaseSummary { /** Keys whose value now follows the new image (image-baked, user untouched). */ adopted: string[]; /** Keys where the user's override was preserved (value differs from old image). */ preserved: string[]; /** Keys present only on the container (user-added, not in either image). */ userOnly: string[]; } /** * Classify how each ENV key was treated by the rebase. Pure; safe to call * with the same inputs passed to rebaseEnvOntoImage. */ export function describeEnvRebase( containerEnv: string[], oldImageEnv: string[], newImageEnv: string[] ): RebaseSummary { const container = parseEnv(containerEnv); const oldImage = parseEnv(oldImageEnv); const newImage = parseEnv(newImageEnv); const adopted: string[] = []; const preserved: string[] = []; const userOnly: string[] = []; for (const [k, v] of container) { const isUser = !oldImage.has(k) || oldImage.get(k) !== v; if (isUser) { if (newImage.has(k) || oldImage.has(k)) preserved.push(k); else userOnly.push(k); } else if (newImage.has(k) && newImage.get(k) !== oldImage.get(k)) { // Image-baked, user untouched, and the new image changed the value. adopted.push(k); } } // Keys the new image introduces that the container never had. for (const k of newImage.keys()) { if (!container.has(k)) adopted.push(k); } return { adopted, preserved, userOnly }; } /** * Classify how each LABEL key was treated by the rebase. Protected * operational labels are reported as preserved. */ export function describeLabelRebase( containerLabels: Record | null | undefined, oldImageLabels: Record | null | undefined, newImageLabels: Record | null | undefined ): RebaseSummary { const container = containerLabels || {}; const oldImage = oldImageLabels || {}; const newImage = newImageLabels || {}; const adopted: string[] = []; const preserved: string[] = []; const userOnly: string[] = []; for (const [k, v] of Object.entries(container)) { const isUser = isProtectedLabel(k) || !(k in oldImage) || oldImage[k] !== v; if (isUser) { if (k in newImage || k in oldImage) preserved.push(k); else userOnly.push(k); } else if (k in newImage && newImage[k] !== oldImage[k]) { adopted.push(k); } } for (const k of Object.keys(newImage)) { if (!(k in container)) adopted.push(k); } return { adopted, preserved, userOnly }; } /** * Rebase a container's labels onto the new image's defaults. Same * value-aware logic as env, plus operational labels (compose/dockhand) * are always preserved from the container. */ export function rebaseLabelsOntoImage( containerLabels: Record | null | undefined, oldImageLabels: Record | null | undefined, newImageLabels: Record | null | undefined ): Record { const container = containerLabels || {}; const oldImage = oldImageLabels || {}; const newImage = newImageLabels || {}; // User layer: container labels that differ from (or are absent in) the old image. const userLayer: Record = {}; for (const [k, v] of Object.entries(container)) { if (!(k in oldImage) || oldImage[k] !== v) { userLayer[k] = v; } } // Base = new image defaults, user layer wins. const out: Record = { ...newImage, ...userLayer }; // Force-preserve operational labels from the container regardless of the image. for (const [k, v] of Object.entries(container)) { if (isProtectedLabel(k)) { out[k] = v; } } return out; }