feat(tools): add kubernetes homelab awareness tools

This commit is contained in:
William Valentin
2026-02-16 14:31:33 -08:00
parent 21c986b671
commit 63df791b26
14 changed files with 501 additions and 4 deletions
+1
View File
@@ -30,6 +30,7 @@ export { createGtasksTools } from './gtasks.js';
export { createMinioShareTool } from './minio-share.js';
export { createMinioIngestTool } from './minio-ingest.js';
export { createMinioSyncTool } from './minio-sync.js';
export { createK8sTools } from './k8s.js';
export { screenCaptureTool, cameraCaptureTool } from './capture.js';
import type { Tool } from '../types.js';
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from 'vitest';
import { createK8sTools } from './k8s.js';
import type { K8sConfig } from '../../config/index.js';
function makeConfig(overrides?: Partial<NonNullable<K8sConfig>>): NonNullable<K8sConfig> {
return {
enabled: true,
kubectl_path: 'kubectl',
default_namespace: 'default',
allowed_namespaces: [],
...overrides,
};
}
describe('createK8sTools', () => {
it('lists pods', async () => {
const runner = vi.fn(async () => ({
stdout: JSON.stringify({
items: [{
metadata: { namespace: 'default', name: 'api-123' },
status: {
phase: 'Running',
containerStatuses: [{ ready: true, restartCount: 1 }],
},
}],
}),
stderr: '',
}));
const tools = createK8sTools(makeConfig(), { runner });
const podsTool = tools.find((t) => t.name === 'k8s.pods');
if (!podsTool) {throw new Error('k8s.pods tool missing');}
const result = await podsTool.execute({});
expect(result.success).toBe(true);
expect(result.output).toContain('default/api-123');
expect(result.output).toContain('phase=Running');
expect(runner).toHaveBeenCalledWith('kubectl', ['get', 'pods', '-o', 'json', '-n', 'default'], expect.any(Object));
});
it('lists deployments across all namespaces', async () => {
const runner = vi.fn(async () => ({
stdout: JSON.stringify({
items: [{
metadata: { namespace: 'ops', name: 'worker' },
spec: { replicas: 3 },
status: { readyReplicas: 2, availableReplicas: 2 },
}],
}),
stderr: '',
}));
const tools = createK8sTools(makeConfig(), { runner });
const tool = tools.find((t) => t.name === 'k8s.deployments');
if (!tool) {throw new Error('k8s.deployments tool missing');}
const result = await tool.execute({ all_namespaces: true });
expect(result.success).toBe(true);
expect(result.output).toContain('ops/worker');
expect(result.output).toContain('replicas=2/3');
});
it('fetches logs with tail + since', async () => {
const runner = vi.fn(async () => ({
stdout: 'line1\nline2\n',
stderr: '',
}));
const tools = createK8sTools(makeConfig({ default_namespace: 'ops' }), { runner });
const tool = tools.find((t) => t.name === 'k8s.logs');
if (!tool) {throw new Error('k8s.logs tool missing');}
const result = await tool.execute({ pod: 'api-123', lines: 50, since: '10m' });
expect(result.success).toBe(true);
expect(result.output).toContain('line1');
expect(runner).toHaveBeenCalledWith(
'kubectl',
['logs', 'api-123', '--tail', '50', '-n', 'ops', '--since', '10m'],
expect.any(Object),
);
});
it('blocks unauthorized namespaces', async () => {
const runner = vi.fn();
const tools = createK8sTools(makeConfig({ allowed_namespaces: ['observability'] }), { runner });
const tool = tools.find((t) => t.name === 'k8s.logs');
if (!tool) {throw new Error('k8s.logs tool missing');}
const result = await tool.execute({ pod: 'api-123', namespace: 'default' });
expect(result.success).toBe(false);
expect(result.error).toContain('not allowed');
expect(runner).not.toHaveBeenCalled();
});
});
+244
View File
@@ -0,0 +1,244 @@
import { promisify } from 'node:util';
import { execFile } from 'node:child_process';
import type { Tool, ToolResult } from '../types.js';
import type { K8sConfig } from '../../config/index.js';
const execFileAsync = promisify(execFile);
type Runner = (
file: string,
args: string[],
options?: { env?: NodeJS.ProcessEnv; maxBuffer?: number },
) => Promise<{ stdout: string; stderr: string }>;
interface PodItem {
metadata?: { name?: string; namespace?: string };
status?: { phase?: string; containerStatuses?: Array<{ ready?: boolean; restartCount?: number }> };
}
interface DeploymentItem {
metadata?: { name?: string; namespace?: string };
spec?: { replicas?: number };
status?: { readyReplicas?: number; availableReplicas?: number };
}
function resolveNamespace(argsNamespace: string | undefined, cfg: NonNullable<K8sConfig>): string | undefined {
return argsNamespace ?? cfg.default_namespace;
}
function assertAllowedNamespace(namespace: string | undefined, cfg: NonNullable<K8sConfig>): string | null {
if (!namespace) {return null;}
if (!cfg.allowed_namespaces || cfg.allowed_namespaces.length === 0) {
return null;
}
return cfg.allowed_namespaces.includes(namespace)
? null
: `Namespace "${namespace}" is not allowed by k8s.allowed_namespaces`;
}
function parseJson<T>(value: string): T {
return JSON.parse(value) as T;
}
function formatPodReady(statuses?: Array<{ ready?: boolean }>): string {
const total = statuses?.length ?? 0;
const ready = statuses?.filter((s) => s.ready).length ?? 0;
return `${ready}/${total}`;
}
function formatPodRestarts(statuses?: Array<{ restartCount?: number }>): number {
return (statuses ?? []).reduce((sum, s) => sum + (s.restartCount ?? 0), 0);
}
export interface K8sToolDeps {
runner?: Runner;
}
export function createK8sTools(config: NonNullable<K8sConfig>, deps?: K8sToolDeps): Tool[] {
const runner = deps?.runner ?? (async (file: string, args: string[], options?: { env?: NodeJS.ProcessEnv; maxBuffer?: number }) => {
return execFileAsync(file, args, options);
});
const kubectl = config.kubectl_path;
const podsTool: Tool = {
name: 'k8s.pods',
description: 'List Kubernetes pods in a namespace or across all namespaces.',
inputSchema: {
type: 'object',
properties: {
namespace: { type: 'string', description: 'Namespace to query (defaults to k8s.default_namespace)' },
all_namespaces: { type: 'boolean', description: 'Query all namespaces' },
selector: { type: 'string', description: 'Label selector (e.g. "app=api")' },
limit: { type: 'number', description: 'Maximum pods to display (default 25)' },
},
required: [],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as { namespace?: string; all_namespaces?: boolean; selector?: string; limit?: number };
try {
if (args.all_namespaces && config.allowed_namespaces.length > 0) {
return {
success: false,
output: '',
error: 'all_namespaces=true is not allowed when k8s.allowed_namespaces is configured',
};
}
const namespace = resolveNamespace(args.namespace, config);
const nsError = assertAllowedNamespace(namespace, config);
if (nsError) {
return { success: false, output: '', error: nsError };
}
const cmdArgs = ['get', 'pods', '-o', 'json'];
if (args.all_namespaces) {
cmdArgs.push('-A');
} else if (namespace) {
cmdArgs.push('-n', namespace);
}
if (args.selector) {
cmdArgs.push('-l', args.selector);
}
const { stdout } = await runner(kubectl, cmdArgs, { maxBuffer: 10 * 1024 * 1024 });
const parsed = parseJson<{ items: PodItem[] }>(typeof stdout === 'string' ? stdout : stdout.toString('utf-8'));
const limit = Math.max(1, Math.floor(args.limit ?? 25));
const items = (parsed.items ?? []).slice(0, limit);
if (items.length === 0) {
return { success: true, output: 'No pods found.' };
}
const lines = items.map((item) => {
const ns = item.metadata?.namespace ?? '-';
const name = item.metadata?.name ?? '-';
const phase = item.status?.phase ?? 'Unknown';
const ready = formatPodReady(item.status?.containerStatuses);
const restarts = formatPodRestarts(item.status?.containerStatuses);
return `- ${ns}/${name} phase=${phase} ready=${ready} restarts=${restarts}`;
});
return {
success: true,
output: `Pods (${items.length} shown):\n${lines.join('\n')}`,
};
} catch (error) {
return { success: false, output: '', error: error instanceof Error ? error.message : String(error) };
}
},
};
const deploymentsTool: Tool = {
name: 'k8s.deployments',
description: 'List Kubernetes deployments in a namespace or across all namespaces.',
inputSchema: {
type: 'object',
properties: {
namespace: { type: 'string', description: 'Namespace to query (defaults to k8s.default_namespace)' },
all_namespaces: { type: 'boolean', description: 'Query all namespaces' },
selector: { type: 'string', description: 'Label selector (e.g. "app=api")' },
limit: { type: 'number', description: 'Maximum deployments to display (default 25)' },
},
required: [],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as { namespace?: string; all_namespaces?: boolean; selector?: string; limit?: number };
try {
if (args.all_namespaces && config.allowed_namespaces.length > 0) {
return {
success: false,
output: '',
error: 'all_namespaces=true is not allowed when k8s.allowed_namespaces is configured',
};
}
const namespace = resolveNamespace(args.namespace, config);
const nsError = assertAllowedNamespace(namespace, config);
if (nsError) {
return { success: false, output: '', error: nsError };
}
const cmdArgs = ['get', 'deployments', '-o', 'json'];
if (args.all_namespaces) {
cmdArgs.push('-A');
} else if (namespace) {
cmdArgs.push('-n', namespace);
}
if (args.selector) {
cmdArgs.push('-l', args.selector);
}
const { stdout } = await runner(kubectl, cmdArgs, { maxBuffer: 10 * 1024 * 1024 });
const parsed = parseJson<{ items: DeploymentItem[] }>(typeof stdout === 'string' ? stdout : stdout.toString('utf-8'));
const limit = Math.max(1, Math.floor(args.limit ?? 25));
const items = (parsed.items ?? []).slice(0, limit);
if (items.length === 0) {
return { success: true, output: 'No deployments found.' };
}
const lines = items.map((item) => {
const ns = item.metadata?.namespace ?? '-';
const name = item.metadata?.name ?? '-';
const desired = item.spec?.replicas ?? 0;
const ready = item.status?.readyReplicas ?? 0;
const available = item.status?.availableReplicas ?? 0;
return `- ${ns}/${name} replicas=${ready}/${desired} available=${available}`;
});
return {
success: true,
output: `Deployments (${items.length} shown):\n${lines.join('\n')}`,
};
} catch (error) {
return { success: false, output: '', error: error instanceof Error ? error.message : String(error) };
}
},
};
const logsTool: Tool = {
name: 'k8s.logs',
description: 'Fetch recent logs for a Kubernetes pod.',
inputSchema: {
type: 'object',
properties: {
pod: { type: 'string', description: 'Pod name' },
namespace: { type: 'string', description: 'Namespace (defaults to k8s.default_namespace)' },
container: { type: 'string', description: 'Container name (if pod has multiple containers)' },
lines: { type: 'number', description: 'Tail lines (default 200, max 2000)' },
since: { type: 'string', description: 'Only return logs newer than duration (e.g. "10m", "1h")' },
},
required: ['pod'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as { pod: string; namespace?: string; container?: string; lines?: number; since?: string };
try {
const namespace = resolveNamespace(args.namespace, config);
const nsError = assertAllowedNamespace(namespace, config);
if (nsError) {
return { success: false, output: '', error: nsError };
}
const tail = Math.max(1, Math.min(2000, Math.floor(args.lines ?? 200)));
const cmdArgs = ['logs', args.pod, '--tail', String(tail)];
if (namespace) {
cmdArgs.push('-n', namespace);
}
if (args.container) {
cmdArgs.push('-c', args.container);
}
if (args.since) {
cmdArgs.push('--since', args.since);
}
const { stdout } = await runner(kubectl, cmdArgs, { maxBuffer: 10 * 1024 * 1024 });
const text = typeof stdout === 'string' ? stdout : stdout.toString('utf-8');
const body = text.trim();
return {
success: true,
output: body.length > 0 ? body : '(no log output)',
};
} catch (error) {
return { success: false, output: '', error: error instanceof Error ? error.message : String(error) };
}
},
};
return [podsTool, deploymentsTool, logsTool];
}
+1 -1
View File
@@ -5,7 +5,7 @@ export { ToolExecutor } from './executor.js';
export type { ToolExecutorConfig } from './executor.js';
export { ToolPolicy } from './policy.js';
export type { ToolPolicyContext } from './policy.js';
export { allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, BrowserManager, createBrowserTools, createMediaSendTool, createAudioTranscribeTool, createSessionTools, createAgentsListTool, createMessageSendTool, createCronTools, createGmailTools, createGcalTools, createGdocsTools, createGdriveTools, createGtasksTools, createMinioShareTool, createMinioIngestTool, createMinioSyncTool } from './builtin/index.js';
export { allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, BrowserManager, createBrowserTools, createMediaSendTool, createAudioTranscribeTool, createSessionTools, createAgentsListTool, createMessageSendTool, createCronTools, createGmailTools, createGcalTools, createGdocsTools, createGdriveTools, createGtasksTools, createMinioShareTool, createMinioIngestTool, createMinioSyncTool, createK8sTools } from './builtin/index.js';
export type { WebSearchConfig } from './builtin/web-search.js';
export type { ProcessManagerConfig } from './builtin/process/index.js';
export type { BrowserManagerConfig } from './builtin/browser/index.js';
+16
View File
@@ -20,6 +20,9 @@ const ALL_TOOL_NAMES = [
'minio.share',
'minio.ingest',
'minio.sync',
'k8s.pods',
'k8s.deployments',
'k8s.logs',
'process.start',
'process.status',
'process.output',
@@ -497,6 +500,19 @@ describe('ToolPolicy', () => {
expect(names).not.toContain('shell.exec');
});
it('expands group:k8s', () => {
const policy = new ToolPolicy(defaultConfig({
profile: 'minimal',
allow: ['group:k8s'],
}));
const result = policy.filterTools(ALL_TOOLS);
const names = result.map(t => t.name);
expect(names).toContain('k8s.pods');
expect(names).toContain('k8s.deployments');
expect(names).toContain('k8s.logs');
expect(names).not.toContain('shell.exec');
});
it('unknown group name passes through as literal', () => {
const policy = new ToolPolicy(defaultConfig({
profile: 'minimal',
+7
View File
@@ -42,6 +42,9 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
'minio.share',
'minio.ingest',
'minio.sync',
'k8s.pods',
'k8s.deployments',
'k8s.logs',
]),
coding: new Set([
'file.read',
@@ -73,6 +76,9 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
'minio.share',
'minio.ingest',
'minio.sync',
'k8s.pods',
'k8s.deployments',
'k8s.logs',
'file.write',
'file.edit',
'file.patch',
@@ -109,6 +115,7 @@ export const TOOL_GROUPS: Record<string, string[]> = {
'group:gtasks': ['tasks.lists', 'tasks.list'],
'group:cron': ['cron.list', 'cron.trigger', 'cron.create', 'cron.delete'],
'group:minio': ['minio.share', 'minio.ingest', 'minio.sync'],
'group:k8s': ['k8s.pods', 'k8s.deployments', 'k8s.logs'],
};
/** Expand group references in a list of tool names/patterns. */