fix(nodes): close capability-gating gaps in node compatibility (#1261)

* fix(nodes): close capability-gating gaps in node compatibility

Vulnerability scanning is now gated correctly on whether the active node
advertises support for it:

- A node without the Trivy binary stops advertising the scanning capability.
  Previously the capability was toggled only on a state change, so a node that
  booted without Trivy kept advertising scanning it could not perform.
- The control node's own capability list now reflects features disabled at
  runtime, matching what it advertises to peers.
- The scan history surface shows a clear "not available on this node" card,
  with its header actions hidden, instead of attempting a request that fails.

A node's version and capability metadata now refreshes immediately after a
connection test or a completed update, rather than waiting out the cache.

Capability gates fail closed to the unavailable card when a node's metadata
request errors, instead of staying open until the next fetch.

Adds a test that fails if the frontend and backend capability lists drift,
plus coverage for the metadata error path, the runtime-disabled local meta,
the scanning capability sync, and the metadata cache invalidation paths.

* fix(nodes): refresh node metadata client-side after a connection test

A connection test dropped the server-side metadata cache, but the dashboard
kept its own cached copy until the client TTL expired, so version and
capability gates could stay stale in the browser. The test now forces a
client-side metadata refresh for that node, so the version pill and gates
reflect the node's current state immediately.

Also strips any URL userinfo before logging the metadata fetch target, and
makes the scanning-capability detection test deterministically exercise the
no-binary disable path rather than depending on whether the runner has Trivy.
This commit is contained in:
Anso
2026-05-31 20:28:18 -04:00
committed by GitHub
parent 6fc7f200a6
commit d03d97d964
16 changed files with 441 additions and 37 deletions
+18 -2
View File
@@ -3,6 +3,7 @@ import path from 'path';
import fs from 'fs';
import semver from 'semver';
import { SENCHO_VERSION } from '../generated/version';
import { isDebugEnabled } from '../utils/debug';
/**
* Static registry of capabilities supported by THIS Sencho instance.
@@ -125,23 +126,38 @@ export const OFFLINE_META: RemoteMeta = {
online: false,
};
/** Strip any `user:pass@` userinfo from a URL so credentials never reach the logs. */
function redactUrlCredentials(url: string): string {
return url.replace(/(\/\/)[^/@]*@/, '$1');
}
/** Fetch /api/meta from a remote Sencho instance. Returns empty data on failure. */
export async function fetchRemoteMeta(baseUrl: string, apiToken: string): Promise<RemoteMeta> {
const safeUrl = redactUrlCredentials(baseUrl);
try {
const res = await axios.get(`${baseUrl.replace(/\/$/, '')}/api/meta`, {
headers: apiToken ? { Authorization: `Bearer ${apiToken}` } : {},
timeout: 5000,
});
const rawVersion: string | undefined = res.data.version;
return {
const meta: RemoteMeta = {
version: isValidVersion(rawVersion) ? rawVersion : null,
capabilities: Array.isArray(res.data.capabilities) ? res.data.capabilities : [],
startedAt: typeof res.data.startedAt === 'number' ? res.data.startedAt : null,
updateError: typeof res.data.updateError === 'string' ? res.data.updateError : null,
online: true,
};
if (isDebugEnabled()) {
// Diagnostic aid for "why is this feature gated?": log the resolved version
// and capability count (not the full list) at the one boundary that decides
// gating. The URL is logged with any userinfo credentials stripped.
console.log(
`[CapabilityRegistry:diag] meta ok from ${safeUrl}: version=${meta.version ?? 'null'} capabilities=${meta.capabilities.length}`,
);
}
return meta;
} catch (err) {
console.warn(`[CapabilityRegistry] Failed to fetch meta from ${baseUrl}:`, (err as Error).message);
console.warn(`[CapabilityRegistry] Failed to fetch meta from ${safeUrl}:`, (err as Error).message);
return { ...OFFLINE_META };
}
}
+9 -2
View File
@@ -409,13 +409,20 @@ class TrivyService {
this.detectionTimestamp - started
}`,
);
if (isAvailable && !wasAvailable) {
// Sync the capability unconditionally so a node that boots without Trivy
// (the common case) stops advertising vulnerability-scanning. A transition-only
// toggle missed this: source starts at 'none', so wasAvailable is false on the
// first detection and the disable branch never fired. Set add/delete is idempotent.
if (isAvailable) {
enableCapability('vulnerability-scanning');
} else {
disableCapability('vulnerability-scanning');
}
if (isAvailable && !wasAvailable) {
console.log(
`[Trivy] Binary detected (source=${this.source}); vulnerability scanning enabled (version ${this.version})`,
);
} else if (!isAvailable && wasAvailable) {
disableCapability('vulnerability-scanning');
console.warn('[Trivy] Binary no longer detected; vulnerability scanning disabled');
}
return { available: isAvailable, version: this.version, source: this.source };