mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
d03d97d964
* 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.
164 lines
5.7 KiB
TypeScript
164 lines
5.7 KiB
TypeScript
import axios from 'axios';
|
|
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.
|
|
* Append-only: when a new feature ships, add its capability string here.
|
|
* The frontend uses these flags (not semver comparisons) to gate features
|
|
* on nodes that may be running older versions.
|
|
*/
|
|
export const CAPABILITIES = [
|
|
'stacks',
|
|
'containers',
|
|
'resources',
|
|
'templates',
|
|
'global-logs',
|
|
'system-stats',
|
|
'fleet',
|
|
'auto-updates',
|
|
'labels',
|
|
'webhooks',
|
|
'network-topology',
|
|
'notifications',
|
|
'notification-routing',
|
|
'host-console',
|
|
'container-exec',
|
|
'audit-log',
|
|
'scheduled-ops',
|
|
'sso',
|
|
'api-tokens',
|
|
'users',
|
|
'registries',
|
|
'self-update',
|
|
'vulnerability-scanning',
|
|
] as const;
|
|
|
|
export type Capability = (typeof CAPABILITIES)[number];
|
|
|
|
/** Returns true when the string is a usable semver version. */
|
|
export function isValidVersion(v: string | null | undefined): v is string {
|
|
return !!v && v !== 'unknown' && v !== '0.0.0-dev' && !!semver.valid(v);
|
|
}
|
|
|
|
// Resolved once per process at import time, then cached.
|
|
function resolveVersion(): string | null {
|
|
// Primary: walk up to find the root package.json (always authoritative).
|
|
// The generated SENCHO_VERSION constant can be stale when a branch falls
|
|
// behind a release-please version bump, so we prefer the live value.
|
|
let dir = __dirname;
|
|
for (let i = 0; i < 5; i++) {
|
|
const candidate = path.join(dir, 'package.json');
|
|
try {
|
|
const pkg = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
|
if (pkg.name === 'sencho') return pkg.version;
|
|
} catch { /* not found, keep walking */ }
|
|
dir = path.dirname(dir);
|
|
}
|
|
// Fallback: build-time constant (may be stale in dev, but correct in Docker)
|
|
if (SENCHO_VERSION !== '0.0.0-dev') return SENCHO_VERSION;
|
|
console.warn('[CapabilityRegistry] Could not resolve Sencho version from any source');
|
|
return null;
|
|
}
|
|
|
|
const cachedVersion = resolveVersion();
|
|
|
|
export function getSenchoVersion(): string | null {
|
|
return cachedVersion;
|
|
}
|
|
|
|
export interface RemoteMeta {
|
|
version: string | null;
|
|
capabilities: string[];
|
|
startedAt: number | null;
|
|
/** Error message from a failed self-update attempt on the remote node. */
|
|
updateError: string | null;
|
|
/** True when the /api/meta request succeeded (node is reachable). */
|
|
online: boolean;
|
|
}
|
|
|
|
// Runtime capability overrides; services call disableCapability() during init.
|
|
const disabledCapabilities = new Set<Capability>();
|
|
|
|
export function disableCapability(c: Capability): void {
|
|
disabledCapabilities.add(c);
|
|
}
|
|
|
|
export function enableCapability(c: Capability): void {
|
|
disabledCapabilities.delete(c);
|
|
}
|
|
|
|
/** Returns capabilities this instance actually supports at runtime. */
|
|
export function getActiveCapabilities(): readonly string[] {
|
|
if (disabledCapabilities.size === 0) return CAPABILITIES;
|
|
return CAPABILITIES.filter(c => !disabledCapabilities.has(c));
|
|
}
|
|
|
|
/**
|
|
* Capabilities a pilot-agent process should hide from its own /api/meta because
|
|
* the central->pilot path for them is not yet wired through the reverse tunnel.
|
|
* Surfacing them would let the frontend offer a tab whose click silently falls
|
|
* through to central's local handler.
|
|
*
|
|
* `self-update` is intentionally NOT here: a pilot deployed via Docker Compose
|
|
* picks up the compose labels SelfUpdateService.initialize() needs and toggles
|
|
* the capability on locally; the Fleet Update flow then routes through
|
|
* NodeRegistry.getProxyTarget() so the tunnel carries the trigger.
|
|
*/
|
|
const PILOT_DISABLED_CAPABILITIES: readonly Capability[] = [
|
|
'host-console',
|
|
];
|
|
|
|
/** Disable capabilities that require a central->pilot path that is not yet wired. */
|
|
export function applyPilotModeCapabilityFilter(): void {
|
|
for (const cap of PILOT_DISABLED_CAPABILITIES) disableCapability(cap);
|
|
}
|
|
|
|
/** Shared offline shape returned when a remote node is unreachable. */
|
|
export const OFFLINE_META: RemoteMeta = {
|
|
version: null,
|
|
capabilities: [],
|
|
startedAt: null,
|
|
updateError: null,
|
|
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;
|
|
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 ${safeUrl}:`, (err as Error).message);
|
|
return { ...OFFLINE_META };
|
|
}
|
|
}
|