fix(preflight): suppress node-state checks when the Docker snapshot is unavailable (#1423)

When the Docker daemon is unreachable the node snapshot collection fails and
returns empty sets. The preflight rules read those empty sets as "resource
absent", so a stack referencing an external network or volume got false "not
found" blockers while real host-port and container_name conflicts went
undetected.

Add a nodeStateAvailable flag to the preflight context, mirroring the existing
sourceReadable gate. The six node-state rules now suppress themselves when the
snapshot could not be collected, and a single info advisory reports that the
node-state checks were skipped so a clean pass during an outage is not mistaken
for full coverage.
This commit is contained in:
Anso
2026-06-24 19:48:34 -04:00
committed by GitHub
parent 96b3c49359
commit 2ed01641c8
6 changed files with 158 additions and 21 deletions
+5 -2
View File
@@ -175,7 +175,7 @@ export class ComposeDoctorService {
|| 'Sencho could not run docker compose on this node.';
}
const { nodePorts, existingNetworkNames, existingVolumeNames, existingContainers } = await this.nodeState(nodeId, fsSvc, stackName);
const { nodePorts, existingNetworkNames, existingVolumeNames, existingContainers, nodeStateAvailable } = await this.nodeState(nodeId, fsSvc, stackName);
const bindChecks = model ? await this.resolveBindChecks(model, baseDir) : [];
const { stackIntent, serviceIntents, accessUrlPorts, hasAccessUrls } = this.exposureState(nodeId, stackName);
@@ -207,6 +207,7 @@ export class ComposeDoctorService {
existingNetworkNames,
existingVolumeNames,
existingContainers,
nodeStateAvailable,
bindChecks,
stackIntent,
serviceIntents,
@@ -254,6 +255,7 @@ export class ComposeDoctorService {
existingNetworkNames: Set<string>;
existingVolumeNames: Set<string>;
existingContainers: { name: string; stack: string | null }[];
nodeStateAvailable: boolean;
}> {
try {
const knownStacks = await fsSvc.getStacks();
@@ -265,11 +267,12 @@ export class ComposeDoctorService {
existingNetworkNames: new Set(snapshot.networks.map(n => n.name)),
existingVolumeNames: new Set(snapshot.volumes.map(v => v.name)),
existingContainers: snapshot.containers.map(c => ({ name: c.name, stack: c.stack })),
nodeStateAvailable: true,
};
} catch (error) {
console.warn('[ComposeDoctor] Node snapshot unavailable for %s; node-state checks skipped:',
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
return { nodePorts: [], existingNetworkNames: new Set(), existingVolumeNames: new Set(), existingContainers: [] };
return { nodePorts: [], existingNetworkNames: new Set(), existingVolumeNames: new Set(), existingContainers: [], nodeStateAvailable: false };
}
}
+28 -6
View File
@@ -105,7 +105,7 @@ const envFileMissing: PreflightRule = {
const portConflictNode: PreflightRule = {
id: 'port-conflict-node',
run(ctx) {
if (!ctx.model) return [];
if (!ctx.model || !ctx.nodeStateAvailable) return [];
const byPort = new Map<number, NodePortBinding[]>();
for (const b of ctx.nodePorts) {
const list = byPort.get(b.publishedPort);
@@ -388,10 +388,31 @@ const deploySwarmOnly: PreflightRule = {
},
};
const nodeStateUnavailable: PreflightRule = {
id: 'node-state-unavailable',
run(ctx) {
// Emit only when the model rendered but the node's Docker snapshot could not be
// read: an unrenderable model already raises its own render-failed blocker, so a
// second advisory there would be noise. The rules that read node state suppress
// themselves in this state; this finding tells the operator why, so a clean pass
// during an outage is not mistaken for full coverage. (The info-only
// new-network / new-volume notices are gated too, but left out of the message
// below: they preview a deploy action rather than flag a problem.)
if (!ctx.model || ctx.nodeStateAvailable) return [];
return [{
ruleId: 'node-state-unavailable',
severity: 'info',
title: 'Node-state checks skipped',
message: 'The node\'s Docker state could not be read, so the external-resource, host-port, and container_name checks did not run. This result is partial.',
remediation: 'Confirm the Docker daemon is reachable on this node, then re-run preflight.',
}];
},
};
const externalNetworkMissing: PreflightRule = {
id: 'external-network-missing',
run(ctx) {
if (!ctx.model) return [];
if (!ctx.model || !ctx.nodeStateAvailable) return [];
const findings: PreflightFinding[] = [];
for (const [key, net] of Object.entries(ctx.model.networks)) {
if (!net.external || ctx.existingNetworkNames.has(net.name)) continue;
@@ -411,7 +432,7 @@ const externalNetworkMissing: PreflightRule = {
const externalVolumeMissing: PreflightRule = {
id: 'external-volume-missing',
run(ctx) {
if (!ctx.model) return [];
if (!ctx.model || !ctx.nodeStateAvailable) return [];
const findings: PreflightFinding[] = [];
for (const [key, vol] of Object.entries(ctx.model.volumes)) {
if (!vol.external || ctx.existingVolumeNames.has(vol.name)) continue;
@@ -431,7 +452,7 @@ const externalVolumeMissing: PreflightRule = {
const newNetwork: PreflightRule = {
id: 'new-network',
run(ctx) {
if (!ctx.model) return [];
if (!ctx.model || !ctx.nodeStateAvailable) return [];
const findings: PreflightFinding[] = [];
for (const [key, net] of Object.entries(ctx.model.networks)) {
if (net.external || key === 'default') continue;
@@ -452,7 +473,7 @@ const newNetwork: PreflightRule = {
const newVolume: PreflightRule = {
id: 'new-volume',
run(ctx) {
if (!ctx.model) return [];
if (!ctx.model || !ctx.nodeStateAvailable) return [];
const findings: PreflightFinding[] = [];
for (const [key, vol] of Object.entries(ctx.model.volumes)) {
if (vol.external) continue;
@@ -521,7 +542,7 @@ const containerNameInternalDup: PreflightRule = {
const containerNameCollision: PreflightRule = {
id: 'container-name-collision',
run(ctx) {
if (!ctx.model) return [];
if (!ctx.model || !ctx.nodeStateAvailable) return [];
const findings: PreflightFinding[] = [];
for (const s of ctx.model.services) {
if (!s.containerName) continue;
@@ -723,6 +744,7 @@ export const PREFLIGHT_RULES: PreflightRule[] = [
noRestartPolicy,
noHealthcheck,
deploySwarmOnly,
nodeStateUnavailable,
externalNetworkMissing,
externalVolumeMissing,
newNetwork,
+3
View File
@@ -100,6 +100,9 @@ export interface PreflightContext {
existingNetworkNames: Set<string>;
existingVolumeNames: Set<string>;
existingContainers: { name: string; stack: string | null }[];
/** Whether the node's Docker snapshot was collected; gates node-state checks so
* an unavailable snapshot cannot be mistaken for an empty node. */
nodeStateAvailable: boolean;
bindChecks: BindCheck[];
/** Stack-level exposure classification, or null when unset. */
stackIntent: ExposureIntent | null;