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 -1
View File
@@ -1,3 +1,3 @@
export { loadConfig, deepMerge } from './loader.js';
export { persistConfig } from './persistence.js';
export { configSchema, MODEL_PROVIDERS, type ModelProvider, type Config, type TelegramConfig, type ModelConfig, type CronJobConfig, type AgentsConfig, type CompactionConfig, type ToolProfile, type ToolOverrideConfig, type ToolsConfig, type SandboxConfig, type AgentConfigEntry, type RoutingConfig, type ServerConfig, type BackupConfig } from './schema.js';
export { configSchema, MODEL_PROVIDERS, type ModelProvider, type Config, type TelegramConfig, type ModelConfig, type CronJobConfig, type AgentsConfig, type CompactionConfig, type ToolProfile, type ToolOverrideConfig, type ToolsConfig, type SandboxConfig, type AgentConfigEntry, type RoutingConfig, type ServerConfig, type BackupConfig, type K8sConfig } from './schema.js';
+29
View File
@@ -849,6 +849,35 @@ describe('configSchema — skills watcher', () => {
});
});
describe('configSchema — k8s', () => {
const baseConfig = {
telegram: { bot_token: 'test-token', allowed_chat_ids: [123] },
models: { default: { provider: 'anthropic', model: 'claude-sonnet' } },
};
it('accepts config without k8s section', () => {
const result = configSchema.parse(baseConfig);
expect(result.k8s).toBeUndefined();
});
it('accepts k8s config with namespace restrictions', () => {
const result = configSchema.parse({
...baseConfig,
k8s: {
enabled: true,
kubectl_path: '/usr/local/bin/kubectl',
default_namespace: 'observability',
allowed_namespaces: ['observability', 'platform'],
},
});
expect(result.k8s?.enabled).toBe(true);
expect(result.k8s?.kubectl_path).toBe('/usr/local/bin/kubectl');
expect(result.k8s?.default_namespace).toBe('observability');
expect(result.k8s?.allowed_namespaces).toEqual(['observability', 'platform']);
});
});
describe('configSchema automation', () => {
const baseConfig = {
telegram: { bot_token: 'test-token', allowed_chat_ids: [123] },
+9
View File
@@ -605,6 +605,13 @@ const processSchema = z.object({
buffer_size: z.number().min(1024).max(1048576).default(65536),
}).default({});
const k8sSchema = z.object({
enabled: z.boolean().default(false),
kubectl_path: z.string().default('kubectl'),
default_namespace: z.string().optional(),
allowed_namespaces: z.array(z.string()).default([]),
}).optional();
const retrySchema = z.object({
enabled: z.boolean().default(true),
max_retries: z.number().min(0).max(10).default(3),
@@ -817,6 +824,7 @@ export const configSchema = z.object({
memory: memorySchema,
process: processSchema,
browser: browserSchema,
k8s: k8sSchema,
retry: retrySchema,
web_search: webSearchSchema,
audio: audioSchema,
@@ -846,6 +854,7 @@ export type WebSearchConfig = z.infer<typeof webSearchSchema>;
export type AudioConfig = z.infer<typeof audioSchema>;
export type ProcessConfig = z.infer<typeof processSchema>;
export type BrowserConfig = z.infer<typeof browserSchema>;
export type K8sConfig = z.infer<typeof k8sSchema>;
export type DiscordConfig = z.infer<typeof discordSchema>;
export type SlackConfig = z.infer<typeof slackSchema>;
export type WhatsAppConfig = z.infer<typeof whatsappSchema>;
+6 -1
View File
@@ -27,7 +27,7 @@ import { RoutingPolicy } from '../routing/index.js';
import type { ModelRouter } from '../models/index.js';
import { SessionStore, SessionManager, parseDuration } from '../session/index.js';
import { HookEngine } from '../hooks/index.js';
import { createSessionTools, createAgentsListTool, createMessageSendTool, createCronTools, createGmailTools, createGcalTools, createGdocsTools, createGdriveTools, createGtasksTools, createMinioShareTool, createMinioIngestTool, createMinioSyncTool } from '../tools/index.js';
import { createSessionTools, createAgentsListTool, createMessageSendTool, createCronTools, createGmailTools, createGcalTools, createGdocsTools, createGdriveTools, createGtasksTools, createMinioShareTool, createMinioIngestTool, createMinioSyncTool, createK8sTools } from '../tools/index.js';
import { ChannelRegistry } from '../channels/index.js';
import type { McpManager } from '../mcp/index.js';
import type { SkillRegistry, SkillInstaller } from '../skills/index.js';
@@ -198,6 +198,11 @@ export async function startDaemon(config: Config, options?: StartDaemonOptions):
toolRegistry.register(createMinioSyncTool(config.backup, memoryStore));
}
}
if (config.k8s?.enabled) {
for (const tool of createK8sTools(config.k8s)) {
toolRegistry.register(tool);
}
}
// ── Lifecycle ──
await startServices({ config, lifecycle, channelRegistry, gateway, modelRouter, memoryStore, memoryDir, dataDir });
+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. */