container updates respect the new image's command/entrypoint
This commit is contained in:
4 files changed
+292
-90
No files matched your search
@@ -33,6 +33,39 @@
|
|||||||
export interface ImageEnvLabels {
|
export interface ImageEnvLabels {
|
||||||
Env?: string[];
|
Env?: string[];
|
||||||
Labels?: Record<string, 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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+185
-85
@@ -12,10 +12,12 @@ import * as http from 'node:http';
|
|||||||
import * as https from 'node:https';
|
import * as https from 'node:https';
|
||||||
import * as tls from 'node:tls';
|
import * as tls from 'node:tls';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
import { pumpWebStreamToWritable } from './stream-pump';
|
||||||
|
import { computeRequestTimeoutMs } from './backups/request-timeout';
|
||||||
import type { Environment } from './db';
|
import type { Environment } from './db';
|
||||||
import { getSetting } from './db';
|
import { getSetting } from './db';
|
||||||
import { getAdditionalVolumeBinds } from './mount-dedupe';
|
import { getAdditionalVolumeBinds, dedupeVolumesForRecreate } from './mount-dedupe';
|
||||||
import { rebaseEnvOntoImage, rebaseLabelsOntoImage, describeEnvRebase, describeLabelRebase, type ImageEnvLabels } from './container-env-merge';
|
import { rebaseEnvOntoImage, rebaseLabelsOntoImage, rebaseCommand, describeEnvRebase, describeLabelRebase, type ImageEnvLabels } from './container-env-merge';
|
||||||
import { encodeRegistryAuth } from './registry-auth';
|
import { encodeRegistryAuth } from './registry-auth';
|
||||||
import { isSystemContainer, classifyEmptyDigestImage } from './scheduler/tasks/update-utils';
|
import { isSystemContainer, classifyEmptyDigestImage } from './scheduler/tasks/update-utils';
|
||||||
import { deepDiff } from '../utils/diff.js';
|
import { deepDiff } from '../utils/diff.js';
|
||||||
@@ -417,12 +419,16 @@ export function httpsAgentRequest(
|
|||||||
headers: reqHeaders,
|
headers: reqHeaders,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!streaming) {
|
// A stream request BODY (a large tar PUT) must not get the small-request idle timeout
|
||||||
const isComposeOperation = path === '/_hawser/compose';
|
// even though the RESPONSE isn't streamed - computeRequestTimeoutMs decides on both.
|
||||||
const composeTimeoutMs = parseInt(process.env.COMPOSE_TIMEOUT || '900') * 1000;
|
const streamingBody = typeof (options.body as ReadableStream | undefined)?.getReader === 'function';
|
||||||
const isPrune = path.endsWith('/prune');
|
const timeoutMs = computeRequestTimeoutMs({
|
||||||
reqOptions.timeout = isComposeOperation ? composeTimeoutMs : isPrune ? 300000 : 30000;
|
path,
|
||||||
}
|
streamingBody,
|
||||||
|
streamingResponse: streaming,
|
||||||
|
composeTimeoutSecs: parseInt(process.env.COMPOSE_TIMEOUT || '900'),
|
||||||
|
});
|
||||||
|
if (timeoutMs !== null) reqOptions.timeout = timeoutMs;
|
||||||
|
|
||||||
// Honor AbortSignal from caller (e.g., AbortSignal.timeout(5000) for ping)
|
// Honor AbortSignal from caller (e.g., AbortSignal.timeout(5000) for ping)
|
||||||
const signal = options.signal as AbortSignal | undefined;
|
const signal = options.signal as AbortSignal | undefined;
|
||||||
@@ -493,19 +499,10 @@ export function httpsAgentRequest(
|
|||||||
} else if (body instanceof Blob) {
|
} else if (body instanceof Blob) {
|
||||||
body.arrayBuffer().then(ab => req.end(Buffer.from(ab)), reject);
|
body.arrayBuffer().then(ab => req.end(Buffer.from(ab)), reject);
|
||||||
} else if (typeof (body as ReadableStream).getReader === 'function') {
|
} else if (typeof (body as ReadableStream).getReader === 'function') {
|
||||||
|
// Stream the body with backpressure (O(1) RAM). Without awaiting 'drain' this
|
||||||
|
// piled the whole tar into RAM on a slow mTLS peer - see stream-pump.ts.
|
||||||
const reader = (body as ReadableStream<Uint8Array>).getReader();
|
const reader = (body as ReadableStream<Uint8Array>).getReader();
|
||||||
(async () => {
|
pumpWebStreamToWritable(reader, req).catch(() => { /* req.destroy already called */ });
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
req.write(value);
|
|
||||||
}
|
|
||||||
req.end();
|
|
||||||
} catch (err) {
|
|
||||||
req.destroy(err as Error);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
} else {
|
} else {
|
||||||
req.end();
|
req.end();
|
||||||
}
|
}
|
||||||
@@ -574,7 +571,7 @@ function buildConfigFromEnv(env: Environment): DockerClientConfig {
|
|||||||
/**
|
/**
|
||||||
* Get Docker client configuration for an environment
|
* Get Docker client configuration for an environment
|
||||||
*/
|
*/
|
||||||
async function getDockerConfig(envId?: number | null): Promise<DockerClientConfig> {
|
export async function getDockerConfig(envId?: number | null): Promise<DockerClientConfig> {
|
||||||
if (!envId) {
|
if (!envId) {
|
||||||
throw new Error('No environment specified');
|
throw new Error('No environment specified');
|
||||||
}
|
}
|
||||||
@@ -2016,7 +2013,12 @@ export async function recreateContainerFromInspect(
|
|||||||
let oldImg = oldImageConfig ?? null;
|
let oldImg = oldImageConfig ?? null;
|
||||||
if (!oldImg) {
|
if (!oldImg) {
|
||||||
const oldInspect = await inspectImage(inspectData.Image || config.Image, envId);
|
const oldInspect = await inspectImage(inspectData.Image || config.Image, envId);
|
||||||
oldImg = { Env: (oldInspect as any)?.Config?.Env, Labels: (oldInspect as any)?.Config?.Labels };
|
oldImg = {
|
||||||
|
Env: (oldInspect as any)?.Config?.Env,
|
||||||
|
Labels: (oldInspect as any)?.Config?.Labels,
|
||||||
|
Cmd: (oldInspect as any)?.Config?.Cmd ?? null,
|
||||||
|
Entrypoint: (oldInspect as any)?.Config?.Entrypoint ?? null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const newImg = await inspectImage(newImage, envId);
|
const newImg = await inspectImage(newImage, envId);
|
||||||
const oldEnv = oldImg?.Env || [];
|
const oldEnv = oldImg?.Env || [];
|
||||||
@@ -2026,6 +2028,18 @@ export async function recreateContainerFromInspect(
|
|||||||
createConfig.Env = rebaseEnvOntoImage(config.Env || [], oldEnv, newEnv);
|
createConfig.Env = rebaseEnvOntoImage(config.Env || [], oldEnv, newEnv);
|
||||||
createConfig.Labels = rebaseLabelsOntoImage(config.Labels || {}, oldLabels, newLabels);
|
createConfig.Labels = rebaseLabelsOntoImage(config.Labels || {}, oldLabels, newLabels);
|
||||||
|
|
||||||
|
// 4b. Rebase Cmd/Entrypoint the same way (#1371): a container's Config.Cmd is flattened at
|
||||||
|
// create time, so the OLD image's baked CMD is indistinguishable from a user `command:`
|
||||||
|
// override and would be frozen onto the new image - a container can then start with a
|
||||||
|
// command the new image no longer provides (missing script -> exit 127 -> restart loop).
|
||||||
|
// oldImg?.Cmd == null (caller didn't capture it / inspect failed) -> keep verbatim.
|
||||||
|
const cmdR = rebaseCommand(config.Cmd, oldImg?.Cmd, (newImg as any)?.Config?.Cmd);
|
||||||
|
const entR = rebaseCommand(config.Entrypoint, oldImg?.Entrypoint, (newImg as any)?.Config?.Entrypoint);
|
||||||
|
createConfig.Cmd = cmdR.value;
|
||||||
|
createConfig.Entrypoint = entR.value;
|
||||||
|
if (cmdR.adopted) log?.(`Cmd — adopted from new image: ${JSON.stringify(cmdR.value)} (was ${JSON.stringify(config.Cmd)})`);
|
||||||
|
if (entR.adopted) log?.(`Entrypoint — adopted from new image: ${JSON.stringify(entR.value)} (was ${JSON.stringify(config.Entrypoint)})`);
|
||||||
|
|
||||||
const envSummary = describeEnvRebase(config.Env || [], oldEnv, newEnv);
|
const envSummary = describeEnvRebase(config.Env || [], oldEnv, newEnv);
|
||||||
const labelSummary = describeLabelRebase(config.Labels || {}, oldLabels, newLabels);
|
const labelSummary = describeLabelRebase(config.Labels || {}, oldLabels, newLabels);
|
||||||
const fmt = (label: string, keys: string[]) => keys.length ? `${label}: ${keys.join(', ')}` : '';
|
const fmt = (label: string, keys: string[]) => keys.length ? `${label}: ${keys.join(', ')}` : '';
|
||||||
@@ -2094,39 +2108,20 @@ export async function recreateContainerFromInspect(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduplicate: remove Config.Volumes entries that conflict with HostConfig.Tmpfs or Binds.
|
// Deduplicate Config.Volumes against every mount point that will exist on the new container
|
||||||
// Read-only containers get tmpfs at paths like /tmp that may also be declared as image volumes.
|
// (Tmpfs, existing binds, the binds we re-add below, and inspect.Mounts). Docker rejects
|
||||||
// Docker rejects duplicate mount points, so the tmpfs/bind mount wins over the volume declaration.
|
// duplicate mount points; the tmpfs/bind/volume mount wins over the bare image-VOLUME
|
||||||
if (createConfig.Volumes && hostConfig) {
|
// declaration. Compute additionalBinds first so the dedup sees them (#1088 / #1363).
|
||||||
const mountedPaths = new Set<string>();
|
const additionalBinds = getAdditionalVolumeBinds(hostConfig || {}, inspectData.Mounts || []);
|
||||||
if (hostConfig.Tmpfs) {
|
if (hostConfig) {
|
||||||
for (const p of Object.keys(hostConfig.Tmpfs)) {
|
const kept = dedupeVolumesForRecreate(createConfig.Volumes, hostConfig, inspectData.Mounts || [], additionalBinds);
|
||||||
mountedPaths.add(p);
|
if (kept) createConfig.Volumes = kept;
|
||||||
}
|
else delete createConfig.Volumes;
|
||||||
}
|
|
||||||
if (hostConfig.Binds) {
|
|
||||||
for (const b of hostConfig.Binds) {
|
|
||||||
const parts = b.split(':');
|
|
||||||
if (parts.length >= 2) mountedPaths.add(parts[1].split(':')[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (mountedPaths.size > 0) {
|
|
||||||
for (const volPath of Object.keys(createConfig.Volumes)) {
|
|
||||||
if (mountedPaths.has(volPath)) {
|
|
||||||
delete createConfig.Volumes[volPath];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Object.keys(createConfig.Volumes).length === 0) {
|
|
||||||
delete createConfig.Volumes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const additionalBinds = getAdditionalVolumeBinds(hostConfig, inspectData.Mounts || []);
|
|
||||||
if (additionalBinds.length > 0) {
|
if (additionalBinds.length > 0) {
|
||||||
createConfig.HostConfig = {
|
createConfig.HostConfig = {
|
||||||
...hostConfig,
|
...hostConfig,
|
||||||
Binds: [...(hostConfig.Binds || []), ...additionalBinds]
|
Binds: [...(hostConfig?.Binds || []), ...additionalBinds]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2298,32 +2293,18 @@ export async function createContainerFromMetadata(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Volume/Tmpfs deduplication
|
// Preserve anonymous volumes, then dedup Config.Volumes against every resulting mount point
|
||||||
if (createConfig.Volumes && hostConfig) {
|
// (incl. these added binds + metadata.mounts) so recreate never sends a duplicate (#1088 / #1363).
|
||||||
const mountedPaths = new Set<string>();
|
const additionalBinds = getAdditionalVolumeBinds(hostConfig || {}, metadata.mounts || []);
|
||||||
if (hostConfig.Tmpfs) {
|
if (hostConfig) {
|
||||||
for (const p of Object.keys(hostConfig.Tmpfs)) mountedPaths.add(p);
|
const kept = dedupeVolumesForRecreate(createConfig.Volumes, hostConfig, metadata.mounts || [], additionalBinds);
|
||||||
}
|
if (kept) createConfig.Volumes = kept;
|
||||||
if (hostConfig.Binds) {
|
else delete createConfig.Volumes;
|
||||||
for (const b of hostConfig.Binds) {
|
|
||||||
const parts = b.split(':');
|
|
||||||
if (parts.length >= 2) mountedPaths.add(parts[1].split(':')[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (mountedPaths.size > 0) {
|
|
||||||
for (const volPath of Object.keys(createConfig.Volumes)) {
|
|
||||||
if (mountedPaths.has(volPath)) delete createConfig.Volumes[volPath];
|
|
||||||
}
|
|
||||||
if (Object.keys(createConfig.Volumes).length === 0) delete createConfig.Volumes;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve anonymous volumes
|
|
||||||
const additionalBinds = getAdditionalVolumeBinds(hostConfig, metadata.mounts || []);
|
|
||||||
if (additionalBinds.length > 0) {
|
if (additionalBinds.length > 0) {
|
||||||
createConfig.HostConfig = {
|
createConfig.HostConfig = {
|
||||||
...hostConfig,
|
...hostConfig,
|
||||||
Binds: [...(hostConfig.Binds || []), ...additionalBinds]
|
Binds: [...(hostConfig?.Binds || []), ...additionalBinds]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3399,6 +3380,10 @@ export async function getRegistryAuth(
|
|||||||
return { baseUrl, orgPath: parsed.path, authHeader };
|
return { baseUrl, orgPath: parsed.path, authHeader };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Catalog-failure classification lives in an import-light module so it's unit-testable
|
||||||
|
// without pulling this file's better-sqlite3 imports into the test process (#873).
|
||||||
|
export { classifyCatalogFailure, CATALOG_NOT_SUPPORTED_MSG } from './registry-catalog.js';
|
||||||
|
|
||||||
// --- Harbor fallback for catalog and image search ---
|
// --- Harbor fallback for catalog and image search ---
|
||||||
// Harbor denies access to the V2 _catalog endpoint for robot accounts.
|
// Harbor denies access to the V2 _catalog endpoint for robot accounts.
|
||||||
// We detect Harbor and use the native project API as a fallback.
|
// We detect Harbor and use the native project API as a fallback.
|
||||||
@@ -4538,11 +4523,16 @@ export async function pruneImages(dangling = true, envId?: number | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { DEFAULT_GRYPE_IMAGE, DEFAULT_TRIVY_IMAGE } = await import('./scanner');
|
const { DEFAULT_GRYPE_IMAGE, DEFAULT_TRIVY_IMAGE } = await import('./scanner');
|
||||||
|
const { DEFAULT_HELPER_IMAGE } = await import('./backups/restic');
|
||||||
const grypeImg = (await getSetting('default_grype_image')) ?? DEFAULT_GRYPE_IMAGE;
|
const grypeImg = (await getSetting('default_grype_image')) ?? DEFAULT_GRYPE_IMAGE;
|
||||||
const trivyImg = (await getSetting('default_trivy_image')) ?? DEFAULT_TRIVY_IMAGE;
|
const trivyImg = (await getSetting('default_trivy_image')) ?? DEFAULT_TRIVY_IMAGE;
|
||||||
|
// The backup/restore helper image is pulled once and reused for every backup; shield it from
|
||||||
|
// prune too (same holder-container trick), so an unused-image prune between backups doesn't
|
||||||
|
// force a slow re-pull (and a fail on an air-tight/offline host).
|
||||||
|
const helperImg = (await getSetting('default_backup_image')) || DEFAULT_HELPER_IMAGE;
|
||||||
|
|
||||||
const holderIds: string[] = [];
|
const holderIds: string[] = [];
|
||||||
for (const image of [grypeImg, trivyImg]) {
|
for (const image of [grypeImg, trivyImg, helperImg]) {
|
||||||
try {
|
try {
|
||||||
const safeName = `dockhand-prune-keep-${image.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}`;
|
const safeName = `dockhand-prune-keep-${image.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}`;
|
||||||
const created = await dockerJsonRequest<{ Id: string }>(
|
const created = await dockerJsonRequest<{ Id: string }>(
|
||||||
@@ -4861,6 +4851,40 @@ export async function runContainer(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run a container with attached streams (for scanners that need real-time output)
|
// Run a container with attached streams (for scanners that need real-time output)
|
||||||
|
/**
|
||||||
|
* DIAGNOSTIC (issue #1344): when a helper's exit code can't be determined, pull the
|
||||||
|
* recent `docker events` for that container so the log shows WHAT acted on it (a
|
||||||
|
* `die` with exitCode, a `kill` with the signal, a `destroy`/`remove` and its actor).
|
||||||
|
* `docker events` carries `Actor.Attributes` incl. the signal and, on some daemons, the
|
||||||
|
* execID/name of who issued it. Bounded, non-streaming (since..until), best-effort.
|
||||||
|
*/
|
||||||
|
async function logRecentContainerEvents(containerId: string, name: string, sinceISO: string | undefined, envId: number | null | undefined): Promise<void> {
|
||||||
|
try {
|
||||||
|
const sinceSec = sinceISO ? Math.floor(new Date(sinceISO).getTime() / 1000) - 2 : Math.floor(Date.now() / 1000) - 120;
|
||||||
|
const untilSec = Math.floor(Date.now() / 1000) + 1;
|
||||||
|
const filters = encodeURIComponent(JSON.stringify({ container: [containerId] }));
|
||||||
|
const resp = await dockerFetch(`/events?since=${sinceSec}&until=${untilSec}&filters=${filters}`, {}, envId);
|
||||||
|
if (!resp.ok) { console.warn(`[runContainerWithStreaming] events fetch for ${name}: HTTP ${resp.status}`); return; }
|
||||||
|
const body = await resp.text();
|
||||||
|
// The events endpoint returns newline-delimited JSON objects.
|
||||||
|
const lines = body.split('\n').map((l) => l.trim()).filter(Boolean);
|
||||||
|
if (lines.length === 0) { console.warn(`[runContainerWithStreaming] no docker events for ${name} in the window (nothing recorded who touched it)`); return; }
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const ev = JSON.parse(line) as { status?: string; Action?: string; Actor?: { Attributes?: Record<string, string> }; time?: number };
|
||||||
|
const action = ev.Action ?? ev.status ?? '(unknown)';
|
||||||
|
const attrs = ev.Actor?.Attributes ?? {};
|
||||||
|
const sig = attrs.signal ? ` signal=${attrs.signal}` : '';
|
||||||
|
const code = attrs.exitCode ? ` exitCode=${attrs.exitCode}` : '';
|
||||||
|
const exec = attrs.execID ? ` execID=${attrs.execID}` : '';
|
||||||
|
console.warn(`[runContainerWithStreaming] EVENT ${name}: ${action}${sig}${code}${exec}`);
|
||||||
|
} catch { /* skip a non-JSON line */ }
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[runContainerWithStreaming] events fetch failed for ${name}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function runContainerWithStreaming(options: {
|
export async function runContainerWithStreaming(options: {
|
||||||
image: string;
|
image: string;
|
||||||
cmd: string[];
|
cmd: string[];
|
||||||
@@ -4890,7 +4914,11 @@ export async function runContainerWithStreaming(options: {
|
|||||||
Image: options.image,
|
Image: options.image,
|
||||||
Cmd: options.cmd,
|
Cmd: options.cmd,
|
||||||
Env: options.env || [],
|
Env: options.env || [],
|
||||||
Labels: options.labels || {},
|
// Every helper spawned here (backup/restore/probe, image scanner) is ephemeral and exits
|
||||||
|
// on purpose, so it must NOT fire a "Container died/exited" notification like a user's
|
||||||
|
// container would. dockhand.notify=false makes the event handler skip it
|
||||||
|
// (isNotifyDisabledByLabel). Caller labels win if they set notify explicitly.
|
||||||
|
Labels: { 'dockhand.notify': 'false', ...(options.labels || {}) },
|
||||||
Tty: false,
|
Tty: false,
|
||||||
HostConfig: {
|
HostConfig: {
|
||||||
Binds: options.binds || [],
|
Binds: options.binds || [],
|
||||||
@@ -4938,6 +4966,12 @@ export async function runContainerWithStreaming(options: {
|
|||||||
// Start container
|
// Start container
|
||||||
await drainResponse(await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, options.envId));
|
await drainResponse(await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, options.envId));
|
||||||
|
|
||||||
|
// DIAGNOSTIC (issue #1344): one line confirming the helper actually started,
|
||||||
|
// with the config that could explain an early death (bind count, network,
|
||||||
|
// user). Pairs with the "exited with code" / "EXIT-CODE INDETERMINATE" lines
|
||||||
|
// so a user's log shows the full lifecycle of a helper that dies mid-run.
|
||||||
|
console.log(`[runContainerWithStreaming] Started ${containerName} (binds=${(options.binds || []).length}, netMode=${options.networkMode ?? 'default'}, user=${options.user ?? 'default'})`);
|
||||||
|
|
||||||
// Create abort controller to cancel stderr stream when container exits
|
// Create abort controller to cancel stderr stream when container exits
|
||||||
// On some Docker hosts (e.g. Synology NAS with older kernels), follow=true
|
// On some Docker hosts (e.g. Synology NAS with older kernels), follow=true
|
||||||
// streams don't close when the container exits, causing indefinite hangs.
|
// streams don't close when the container exits, causing indefinite hangs.
|
||||||
@@ -4960,34 +4994,100 @@ export async function runContainerWithStreaming(options: {
|
|||||||
// Bounded-retry the wait; if it still can't be determined, fall back to
|
// Bounded-retry the wait; if it still can't be determined, fall back to
|
||||||
// inspecting the container's final State, and if that's also unavailable
|
// inspecting the container's final State, and if that's also unavailable
|
||||||
// leave exitCode undefined — the guard below fails closed on undefined.
|
// leave exitCode undefined — the guard below fails closed on undefined.
|
||||||
|
// HELPERS_WAIT_MODE (default 'wait'): the exit signal below is a single
|
||||||
|
// streaming `POST /wait`, held open with no bytes for the whole run. A
|
||||||
|
// socket-proxy / reverse-proxy / tcp DOCKER_HOST with a short idle timeout can
|
||||||
|
// sever it mid-run, leaving the exit code unreadable even though the helper is
|
||||||
|
// fine (#1344). Set HELPERS_WAIT_MODE=poll to POLL short inspect calls instead
|
||||||
|
// (nothing stays open for a proxy to cut). Applies to every helper this function
|
||||||
|
// runs — backup/restore AND the image scanner.
|
||||||
let exitCode: number | undefined;
|
let exitCode: number | undefined;
|
||||||
for (let attempt = 0; attempt < 3 && exitCode === undefined; attempt++) {
|
if (process.env.HELPERS_WAIT_MODE === 'poll') {
|
||||||
try {
|
// Bound by the caller's timeout (backup passes a long 'data' tier, the
|
||||||
const waitResult = await dockerFetch(`/containers/${containerId}/wait`, { method: 'POST', streaming: true }, options.envId);
|
// scanner passes 600_000). Fall back to 1h so poll mode never hangs.
|
||||||
if (!waitResult.ok) await throwDockerError(waitResult);
|
// One line so the log confirms which exit-signal mode is active (helps
|
||||||
const waitData = await waitResult.json() as { StatusCode?: number };
|
// diagnose whether HELPERS_WAIT_MODE=poll actually took effect - #1344).
|
||||||
exitCode = waitData.StatusCode;
|
console.log(`[runContainerWithStreaming] Awaiting exit of ${options.name ?? containerId.slice(0, 12)} via POLL mode`);
|
||||||
console.log(`[runContainerWithStreaming] Container exited with code: ${exitCode}`);
|
const deadline = Date.now() + (options.timeout && options.timeout > 0 ? options.timeout : 3_600_000);
|
||||||
} catch (err) {
|
while (exitCode === undefined && Date.now() < deadline) {
|
||||||
console.warn(`[runContainerWithStreaming] Wait attempt ${attempt + 1} failed: ${(err as Error).message}`);
|
try {
|
||||||
|
const insp = await dockerFetch(`/containers/${containerId}/json`, {}, options.envId);
|
||||||
|
if (insp.ok) {
|
||||||
|
const data = await insp.json() as { State?: { Status?: string; Running?: boolean; Dead?: boolean; ExitCode?: number } };
|
||||||
|
const st = data.State;
|
||||||
|
if (st && st.Running === false && st.Status === 'exited' && typeof st.ExitCode === 'number') {
|
||||||
|
exitCode = st.ExitCode;
|
||||||
|
console.log(`[runContainerWithStreaming] Container exited with code: ${exitCode} (poll mode)`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (st?.Dead || st?.Status === 'removing') break; // gone -> fail-closed diagnostics below
|
||||||
|
} else if (insp.status === 404) {
|
||||||
|
console.warn(`[runContainerWithStreaming] Poll: ${options.name ?? containerId.slice(0, 12)} not found (404) — container removed`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// A dropped short inspect is cheap to retry — keep polling to the deadline
|
||||||
|
// rather than bailing (unlike the streaming /wait path).
|
||||||
|
console.warn(`[runContainerWithStreaming] Poll inspect failed for ${options.name ?? containerId.slice(0, 12)}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
if (exitCode === undefined) await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let attempt = 0; attempt < 3 && exitCode === undefined; attempt++) {
|
||||||
|
try {
|
||||||
|
const waitResult = await dockerFetch(`/containers/${containerId}/wait`, { method: 'POST', streaming: true }, options.envId);
|
||||||
|
if (!waitResult.ok) await throwDockerError(waitResult);
|
||||||
|
const waitData = await waitResult.json() as { StatusCode?: number };
|
||||||
|
exitCode = waitData.StatusCode;
|
||||||
|
console.log(`[runContainerWithStreaming] Container exited with code: ${exitCode}`);
|
||||||
|
} catch (err) {
|
||||||
|
// DIAGNOSTIC (issue #1344): include the attempt count so a user whose
|
||||||
|
// helper's exit can't be read can tell us whether /wait failed once
|
||||||
|
// (transient) or all 3 times (the container vanished / daemon churn).
|
||||||
|
console.warn(`[runContainerWithStreaming] Wait attempt ${attempt + 1}/3 failed for ${options.name ?? containerId.slice(0, 12)}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (exitCode === undefined) {
|
if (exitCode === undefined) {
|
||||||
// Fallback: inspect the container. Only trust a definitively-exited
|
// Fallback: inspect the container. Only trust a definitively-exited
|
||||||
// state; a still-running/restarting/unknown state stays undefined.
|
// state; a still-running/restarting/unknown state stays undefined.
|
||||||
|
let containerStartedAt: string | undefined;
|
||||||
try {
|
try {
|
||||||
const insp = await dockerFetch(`/containers/${containerId}/json`, {}, options.envId);
|
const insp = await dockerFetch(`/containers/${containerId}/json`, {}, options.envId);
|
||||||
if (insp.ok) {
|
if (insp.ok) {
|
||||||
const data = await insp.json() as { State?: { Status?: string; Running?: boolean; ExitCode?: number } };
|
const data = await insp.json() as { State?: { Status?: string; Running?: boolean; Restarting?: boolean; Dead?: boolean; Paused?: boolean; ExitCode?: number; OOMKilled?: boolean; Error?: string; StartedAt?: string; FinishedAt?: string; Pid?: number } };
|
||||||
const st = data.State;
|
const st = data.State;
|
||||||
|
containerStartedAt = st?.StartedAt;
|
||||||
|
// DIAGNOSTIC (issue #1344): /wait gave no code and the fallback is
|
||||||
|
// about to leave exitCode undefined -> the user sees "could not
|
||||||
|
// determine container exit status". Log the FULL final State so a
|
||||||
|
// user hitting this can tell us WHY (OOMKilled, Dead, removed by an
|
||||||
|
// external reaper, still Running = /wait raced, or a daemon Error).
|
||||||
|
console.warn(
|
||||||
|
`[runContainerWithStreaming] EXIT-CODE INDETERMINATE for ${options.name ?? containerId.slice(0, 12)} ` +
|
||||||
|
`State=${JSON.stringify({
|
||||||
|
Status: st?.Status, Running: st?.Running, Restarting: st?.Restarting,
|
||||||
|
Dead: st?.Dead, Paused: st?.Paused, ExitCode: st?.ExitCode,
|
||||||
|
OOMKilled: st?.OOMKilled, Error: st?.Error,
|
||||||
|
StartedAt: st?.StartedAt, FinishedAt: st?.FinishedAt, Pid: st?.Pid,
|
||||||
|
})}`
|
||||||
|
);
|
||||||
if (st && st.Running === false && st.Status === 'exited' && typeof st.ExitCode === 'number') {
|
if (st && st.Running === false && st.Status === 'exited' && typeof st.ExitCode === 'number') {
|
||||||
exitCode = st.ExitCode;
|
exitCode = st.ExitCode;
|
||||||
console.log(`[runContainerWithStreaming] Resolved exit code via inspect: ${exitCode}`);
|
console.log(`[runContainerWithStreaming] Resolved exit code via inspect: ${exitCode}`);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`[runContainerWithStreaming] EXIT-CODE INDETERMINATE for ${options.name ?? containerId.slice(0, 12)}: inspect returned HTTP ${insp.status} (container may already be gone/removed)`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[runContainerWithStreaming] Inspect fallback failed: ${(err as Error).message}`);
|
console.warn(`[runContainerWithStreaming] Inspect fallback failed: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
|
// Still indeterminate -> pull docker events so the log shows WHO/WHAT
|
||||||
|
// acted on the container (die/kill/destroy + actor). Missing piece when
|
||||||
|
// something external removes the helper mid-run (#1344).
|
||||||
|
if (exitCode === undefined) {
|
||||||
|
await logRecentContainerEvents(containerId, options.name ?? containerId.slice(0, 12), containerStartedAt, options.envId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Container exited - abort stderr stream (it may be hanging on some Docker hosts)
|
// Container exited - abort stderr stream (it may be hanging on some Docker hosts)
|
||||||
|
|||||||
@@ -386,12 +386,12 @@ export async function runContainerUpdate(
|
|||||||
// Get the actual image ID from inspect data
|
// Get the actual image ID from inspect data
|
||||||
const currentImageId = inspectData.Image;
|
const currentImageId = inspectData.Image;
|
||||||
|
|
||||||
// Capture the OLD image's Env/Labels BEFORE the pull — the old digest may
|
// Capture the OLD image's Env/Labels/Cmd/Entrypoint BEFORE the pull — the old digest may
|
||||||
// be untagged/GC'd afterward. Forwarded to the env/label rebase (#1226, #1256).
|
// be untagged/GC'd afterward. Forwarded to the env/label/command rebase (#1226, #1256, #1371).
|
||||||
let oldImageConfig: ImageEnvLabels | null = null;
|
let oldImageConfig: ImageEnvLabels | null = null;
|
||||||
try {
|
try {
|
||||||
const oldImg = await inspectImage(currentImageId, envId) as any;
|
const oldImg = await inspectImage(currentImageId, envId) as any;
|
||||||
oldImageConfig = { Env: oldImg?.Config?.Env, Labels: oldImg?.Config?.Labels };
|
oldImageConfig = { Env: oldImg?.Config?.Env, Labels: oldImg?.Config?.Labels, Cmd: oldImg?.Config?.Cmd ?? null, Entrypoint: oldImg?.Config?.Entrypoint ?? null };
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort; rebase falls back if unavailable.
|
// Best-effort; rebase falls back if unavailable.
|
||||||
}
|
}
|
||||||
@@ -669,10 +669,30 @@ export async function recreateContainer(
|
|||||||
|
|
||||||
const inspectData = await inspectContainer(container.id, envId) as any;
|
const inspectData = await inspectContainer(container.id, envId) as any;
|
||||||
const imageName = imageNameOverride || inspectData.Config?.Image;
|
const imageName = imageNameOverride || inspectData.Config?.Image;
|
||||||
|
// Capture the parent's id BEFORE recreate. A recreate gives the parent a NEW id,
|
||||||
|
// and any child using `network_mode: service:parent` / `container:parent` stores
|
||||||
|
// that old id in its own HostConfig.NetworkMode (`container:<oldId>`). After the
|
||||||
|
// parent update those children still point at the dead id and fail to (re)start
|
||||||
|
// with "joining network namespace of container: No such container" (#570).
|
||||||
|
const oldParentId: string = inspectData.Id;
|
||||||
|
|
||||||
log?.(`Recreating container: ${containerName} (image: ${imageName})`);
|
log?.(`Recreating container: ${containerName} (image: ${imageName})`);
|
||||||
|
|
||||||
await recreateContainerFromInspect(inspectData, imageName, envId, log, oldImageConfig);
|
await recreateContainerFromInspect(inspectData, imageName, envId, log, oldImageConfig);
|
||||||
|
|
||||||
|
// Parent recreate SUCCEEDED (a failure would have thrown and rolled the parent
|
||||||
|
// back to its original id, leaving children valid). Repoint any dependent
|
||||||
|
// children at the new parent. Best-effort and fully isolated: a child failure
|
||||||
|
// NEVER changes the parent's already-successful update. No-op when there are no
|
||||||
|
// such children — the common case pays nothing beyond one already-in-hand id.
|
||||||
|
try {
|
||||||
|
const reconnected = await reconnectDependentChildren(oldParentId, containerName, envId, log);
|
||||||
|
if (reconnected > 0) {
|
||||||
|
log?.(`Reconnected ${reconnected} dependent container${reconnected === 1 ? '' : 's'} to the new parent`);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
log?.(`WARNING: dependent-child reconnect step errored (parent update stands): ${e?.message}`);
|
||||||
|
}
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
log?.(`Failed to recreate container: ${error.message}`);
|
log?.(`Failed to recreate container: ${error.message}`);
|
||||||
@@ -680,3 +700,52 @@ export async function recreateContainer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After a parent container is recreated (new id), find containers whose network is
|
||||||
|
* shared into the parent by id (`network_mode: service:X`/`container:X`, which Docker
|
||||||
|
* lowers to `HostConfig.NetworkMode = "container:<parentId>"`) and repoint them at the
|
||||||
|
* new parent. A plain restart is NOT enough — the stale id is baked into the child's
|
||||||
|
* stored NetworkMode, so the child must be RECREATED with the reference rewritten. We
|
||||||
|
* rewrite to `container:<parentName>` (not the new id) so the child also survives future
|
||||||
|
* parent recreations. Best-effort per child; the caller isolates this from the parent
|
||||||
|
* update result. Returns the number of children repointed. (#570)
|
||||||
|
*/
|
||||||
|
async function reconnectDependentChildren(
|
||||||
|
oldParentId: string,
|
||||||
|
newParentName: string,
|
||||||
|
envId: number | undefined,
|
||||||
|
log?: (msg: string) => void,
|
||||||
|
visited: Set<string> = new Set()
|
||||||
|
): Promise<number> {
|
||||||
|
if (visited.has(oldParentId)) return 0; // cycle guard for A<-B<-C chains
|
||||||
|
visited.add(oldParentId);
|
||||||
|
|
||||||
|
const all = await listContainers(true, envId); // include stopped
|
||||||
|
let count = 0;
|
||||||
|
for (const c of all) {
|
||||||
|
if (c.id === oldParentId) continue;
|
||||||
|
let ci: any;
|
||||||
|
try { ci = await inspectContainer(c.id, envId); } catch { continue; }
|
||||||
|
const mode: string = ci.HostConfig?.NetworkMode || '';
|
||||||
|
if (!mode.startsWith('container:')) continue;
|
||||||
|
const ref = mode.slice('container:'.length);
|
||||||
|
// Match the parent by full id, or defensively by short (12-char) id.
|
||||||
|
if (ref !== oldParentId && ref !== oldParentId.slice(0, 12) && !oldParentId.startsWith(ref)) continue;
|
||||||
|
|
||||||
|
log?.(`Reconnecting dependent container "${c.name}" -> container:${newParentName}`);
|
||||||
|
const oldChildId: string = ci.Id;
|
||||||
|
ci.HostConfig.NetworkMode = `container:${newParentName}`;
|
||||||
|
try {
|
||||||
|
const { Id: newChildId } = await recreateContainerFromInspect(ci, ci.Config.Image, envId, log);
|
||||||
|
count++;
|
||||||
|
// Chain: if this child was itself a parent to others, repoint them too.
|
||||||
|
if (newChildId && newChildId !== oldChildId) {
|
||||||
|
count += await reconnectDependentChildren(oldChildId, c.name, envId, log, visited);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
log?.(`WARNING: failed to reconnect dependent "${c.name}": ${e?.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -161,10 +161,10 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
// Capture the OLD image's Env/Labels BEFORE pulling — once the tag is
|
// Capture the OLD image's Env/Labels BEFORE pulling — once the tag is
|
||||||
// repointed the old digest may be untagged/GC'd and un-inspectable.
|
// repointed the old digest may be untagged/GC'd and un-inspectable.
|
||||||
// Used by the env/label rebase in recreateContainer (#1226, #1256).
|
// Used by the env/label rebase in recreateContainer (#1226, #1256).
|
||||||
let oldImageConfig: { Env?: string[]; Labels?: Record<string, string> } | null = null;
|
let oldImageConfig: { Env?: string[]; Labels?: Record<string, string>; Cmd?: string[] | null; Entrypoint?: string[] | null } | null = null;
|
||||||
try {
|
try {
|
||||||
const oldImg = await inspectImage(currentImageId, envIdNum) as any;
|
const oldImg = await inspectImage(currentImageId, envIdNum) as any;
|
||||||
oldImageConfig = { Env: oldImg?.Config?.Env, Labels: oldImg?.Config?.Labels };
|
oldImageConfig = { Env: oldImg?.Config?.Env, Labels: oldImg?.Config?.Labels, Cmd: oldImg?.Config?.Cmd ?? null, Entrypoint: oldImg?.Config?.Entrypoint ?? null };
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort; rebase will fall back if unavailable.
|
// Best-effort; rebase will fall back if unavailable.
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user