275 lines
9.9 KiB
TypeScript
275 lines
9.9 KiB
TypeScript
/**
|
|
* 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<string, string>;
|
|
/** 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<string, EnvValue> {
|
|
const m = new Map<string, EnvValue>();
|
|
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<string, EnvValue>();
|
|
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<string>();
|
|
|
|
// 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<string, string> | null | undefined,
|
|
oldImageLabels: Record<string, string> | null | undefined,
|
|
newImageLabels: Record<string, string> | 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<string, string> | null | undefined,
|
|
oldImageLabels: Record<string, string> | null | undefined,
|
|
newImageLabels: Record<string, string> | null | undefined
|
|
): Record<string, string> {
|
|
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<string, string> = {};
|
|
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<string, string> = { ...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;
|
|
}
|