feat(nodes): add capability-based node compatibility negotiation (#350)

* feat(nodes): add capability-based node compatibility negotiation

Each Sencho instance now exposes /api/meta with its version and supported
capabilities. When the user switches nodes, the frontend fetches this
metadata and disables features the remote node doesn't support via a
CapabilityGate overlay. Version is shown in the node switcher dropdown
and connection test results.

- Backend: CapabilityRegistry with static capability list and fetchRemoteMeta helper
- Backend: /api/meta (public) and /api/nodes/:id/meta (auth) endpoints
- Frontend: NodeContext enhanced with per-node meta caching (5min TTL)
- Frontend: CapabilityGate component with typed Capability union
- Frontend: 13 features wrapped with capability gates
- Docs: node-compatibility.mdx + OpenAPI spec updates

* fix(nodes): revert to require() for package.json version reading

The static import fails in the Docker multi-stage build because the
root package.json is not copied into the backend-builder stage. The
require() call resolves at runtime when the file is available.
This commit is contained in:
Anso
2026-04-03 00:06:34 -04:00
committed by GitHub
parent ec23f8c4c2
commit ee75811e25
20 changed files with 468 additions and 45 deletions
+45 -3
View File
@@ -30,6 +30,7 @@ import { WebhookService } from './services/WebhookService';
import { SSOService } from './services/SSOService';
import { SchedulerService } from './services/SchedulerService';
import { RegistryService } from './services/RegistryService';
import { CAPABILITIES, getSenchoVersion, fetchRemoteMeta } from './services/CapabilityRegistry';
import { CronExpressionParser } from 'cron-parser';
import { isValidStackName, isValidRemoteUrl, isPathWithinBase } from './utils/validation';
import YAML from 'yaml';
@@ -177,7 +178,8 @@ app.use((req: Request, res: Response, next: NextFunction): void => {
!req.path.startsWith('/api/nodes') &&
!req.path.startsWith('/api/license') &&
!req.path.startsWith('/api/fleet') &&
!req.path.startsWith('/api/webhooks')
!req.path.startsWith('/api/webhooks') &&
!req.path.startsWith('/api/meta')
) {
// Preserve body stream for proxy piping
next();
@@ -210,7 +212,8 @@ const nodeContextMiddleware = (req: Request, res: Response, next: NextFunction)
!req.path.startsWith('/api/nodes') &&
!req.path.startsWith('/api/license') &&
!req.path.startsWith('/api/fleet') &&
!req.path.startsWith('/api/webhooks')
!req.path.startsWith('/api/webhooks') &&
!req.path.startsWith('/api/meta')
) {
const node = DatabaseService.getInstance().getNode(req.nodeId);
if (!node) {
@@ -317,6 +320,12 @@ app.get('/api/health', (_req: Request, res: Response): void => {
res.json({ status: 'ok', uptime: process.uptime() });
});
// Public meta endpoint - returns this instance's version and supported capabilities.
// No auth required (like /health). Used by remote nodes during connection tests.
app.get('/api/meta', (_req: Request, res: Response): void => {
res.json({ version: getSenchoVersion(), capabilities: CAPABILITIES });
});
// Auth Routes (no authentication required)
// Check if setup is needed
@@ -2153,7 +2162,7 @@ const remoteNodeProxy = createProxyMiddleware<Request, Response>({
// Intercepts all /api/ requests for remote Distributed API nodes and forwards them
// to the target Sencho instance. Node management and auth routes always execute locally.
app.use('/api/', (req: Request, res: Response, next: NextFunction): void => {
if (req.path.startsWith('/auth/') || req.path.startsWith('/nodes') || req.path.startsWith('/license') || req.path.startsWith('/fleet') || req.path.startsWith('/webhooks')) {
if (req.path.startsWith('/auth/') || req.path.startsWith('/nodes') || req.path.startsWith('/license') || req.path.startsWith('/fleet') || req.path.startsWith('/webhooks') || req.path.startsWith('/meta')) {
next();
return;
}
@@ -5181,6 +5190,39 @@ app.post('/api/nodes/:id/test', async (req: Request, res: Response) => {
}
});
// Fetch capability metadata for a specific node. For local nodes, returns this
// instance's capabilities directly. For remote nodes, relays GET /api/meta from
// the remote Sencho instance. Returns { version: null, capabilities: [] } on
// failure (old node without /api/meta, or node offline) — never an error status.
app.get('/api/nodes/:id/meta', authMiddleware, async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
const node = DatabaseService.getInstance().getNode(id);
if (!node) {
res.status(404).json({ error: 'Node not found' });
return;
}
if (node.type === 'local') {
res.json({ version: getSenchoVersion(), capabilities: CAPABILITIES });
return;
}
// Remote node — relay GET /api/meta
const baseUrl = node.api_url?.replace(/\/$/, '');
if (!baseUrl || !node.api_token) {
res.json({ version: null, capabilities: [] });
return;
}
const meta = await fetchRemoteMeta(baseUrl, node.api_token);
res.json(meta);
} catch (error: any) {
console.error('Failed to fetch node meta:', error);
res.status(500).json({ error: error.message || 'Failed to fetch node metadata' });
}
});
// Serve static files in production (for Docker deployment)
if (process.env.NODE_ENV === 'production') {
@@ -0,0 +1,58 @@
import axios from 'axios';
/**
* 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',
'audit-log',
'scheduled-ops',
'sso',
'api-tokens',
'users',
'registries',
] as const;
export type Capability = (typeof CAPABILITIES)[number];
export function getSenchoVersion(): string {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('../../../package.json').version;
}
export interface RemoteMeta {
version: string | null;
capabilities: string[];
}
/** Fetch /api/meta from a remote Sencho instance. Returns empty data on failure. */
export async function fetchRemoteMeta(baseUrl: string, apiToken: string): Promise<RemoteMeta> {
try {
const res = await axios.get(`${baseUrl}/api/meta`, {
headers: { Authorization: `Bearer ${apiToken}` },
timeout: 5000,
});
return {
version: res.data.version ?? null,
capabilities: Array.isArray(res.data.capabilities) ? res.data.capabilities : [],
};
} catch {
return { version: null, capabilities: [] };
}
}
+6 -1
View File
@@ -1,6 +1,7 @@
import Docker from 'dockerode';
import axios from 'axios';
import { DatabaseService, Node } from './DatabaseService';
import { fetchRemoteMeta } from './CapabilityRegistry';
/**
* NodeRegistry: Manages connections for multiple nodes.
@@ -165,21 +166,25 @@ export class NodeRegistry {
// Step 2: Fetch Docker stats in parallel. Use allSettled so a slow or missing
// endpoint doesn't fail the whole test - each field falls back to '-' gracefully.
const [statsResult, sysResult, imagesResult] = await Promise.allSettled([
const [statsResult, sysResult, imagesResult, metaResult] = await Promise.allSettled([
axios.get(`${baseUrl}/api/stats`, { headers, timeout: 8000 }),
axios.get(`${baseUrl}/api/system/stats`, { headers, timeout: 8000 }),
axios.get(`${baseUrl}/api/system/images`, { headers, timeout: 8000 }),
fetchRemoteMeta(baseUrl, node.api_token!),
]);
const stats = statsResult.status === 'fulfilled' ? statsResult.value.data : null;
const sys = sysResult.status === 'fulfilled' ? sysResult.value.data : null;
const images = imagesResult.status === 'fulfilled' ? imagesResult.value.data : null;
const meta = metaResult.status === 'fulfilled' ? metaResult.value : null;
return {
success: true,
info: {
name: node.name,
serverVersion: 'Remote Sencho',
senchoVersion: meta?.version ?? null,
capabilities: meta?.capabilities ?? [],
os: 'Remote',
architecture: 'Remote',
containers: stats?.total ?? '-',