feat(tools): add kubernetes homelab awareness tools
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user