From 63df791b265a6692b03f8e6ab72d26895619f081 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 14:31:33 -0800 Subject: [PATCH] feat(tools): add kubernetes homelab awareness tools --- README.md | 17 +++ config/default.yaml | 7 + docs/api/TOOLS.md | 50 ++++++- docs/plans/state.json | 23 ++++ src/config/index.ts | 2 +- src/config/schema.test.ts | 29 ++++ src/config/schema.ts | 9 ++ src/daemon/index.ts | 7 +- src/tools/builtin/index.ts | 1 + src/tools/builtin/k8s.test.ts | 91 +++++++++++++ src/tools/builtin/k8s.ts | 244 ++++++++++++++++++++++++++++++++++ src/tools/index.ts | 2 +- src/tools/policy.test.ts | 16 +++ src/tools/policy.ts | 7 + 14 files changed, 501 insertions(+), 4 deletions(-) create mode 100644 src/tools/builtin/k8s.test.ts create mode 100644 src/tools/builtin/k8s.ts diff --git a/README.md b/README.md index 36b8707..48254c3 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Self-hosted personal AI assistant with Telegram and Terminal interfaces. - **Scheduled Backups**: Interval- or cron-based snapshot backups with optional startup run - **MinIO File Sharing Tool**: Upload a local file and return a temporary MinIO share link via `minio.share` - **MinIO Knowledge Ingestion Tool**: Pull text-like objects from MinIO into memory namespaces via `minio.ingest` +- **Kubernetes Homelab Tools**: Inspect pods/deployments and fetch logs via `k8s.pods`, `k8s.deployments`, `k8s.logs` - **Gmail Pub/Sub Watcher**: Monitor Gmail inbox via Google Cloud Pub/Sub push notifications with polling fallback - **Vector Memory Search**: Hybrid keyword + semantic search with embeddings (OpenAI, Gemini, Ollama, llama.cpp, Voyage AI) - **Docker Deployment**: Multi-stage Dockerfile and docker-compose.yml for production containers @@ -629,6 +630,22 @@ When `backup.minio.enabled` is configured, Flynn also exposes MinIO tools: - `minio.ingest`: read a text-like object from MinIO and append/replace a memory namespace (useful for syncing notes/runbooks into long-term memory) - `minio.sync`: recursively ingest a MinIO prefix into nested memory namespaces with object and size limits +## Kubernetes Tools + +Optional Kubernetes tools are available when `k8s.enabled: true`: + +- `k8s.pods`: list pod status (phase, ready count, restarts) +- `k8s.deployments`: list deployment replica readiness +- `k8s.logs`: fetch recent pod logs + +```yaml +k8s: + enabled: true + kubectl_path: kubectl + default_namespace: observability + allowed_namespaces: [observability, platform] +``` + ## Inbound Webhooks HTTP endpoints that trigger agent processing. Each webhook accepts POST requests, optionally verifies an HMAC signature, renders a message template, and routes the agent's response to an output channel. diff --git a/config/default.yaml b/config/default.yaml index 1e23003..706a47d 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -186,6 +186,13 @@ models: # Then reference them in fallback_chain: # fallback_chain: [ollama, llamacpp, local] +# Optional: Kubernetes / homelab awareness tools (k8s.pods, k8s.deployments, k8s.logs) +# k8s: +# enabled: false +# kubectl_path: kubectl +# default_namespace: default +# allowed_namespaces: [] # Empty = allow any namespace; set to restrict access. + hooks: confirm: - shell.* diff --git a/docs/api/TOOLS.md b/docs/api/TOOLS.md index 8a8a013..45bd82c 100644 --- a/docs/api/TOOLS.md +++ b/docs/api/TOOLS.md @@ -28,6 +28,7 @@ Tools are executable capabilities that the AI agent can call to perform actions - **Browser**: `browser.navigate`, `browser.screenshot` - **Memory**: `memory.read`, `memory.write`, `memory.search` - **MinIO**: `minio.share`, `minio.ingest`, `minio.sync` +- **Kubernetes**: `k8s.pods`, `k8s.deployments`, `k8s.logs` - **Media**: `media.send`, `image.analyze`, `audio.transcribe` - **System**: `system.info` - **Session**: `sessions.list`, `sessions.delete` @@ -467,7 +468,7 @@ Tools are organized into groups: - `group:web`: Web and browser tools - `group:memory`: Memory and search tools -There are additional groups for specific integrations (gmail/gcal/gdocs/gdrive/gtasks/cron/minio). See `TOOL_GROUPS` in `src/tools/policy.ts`. +There are additional groups for specific integrations (gmail/gcal/gdocs/gdrive/gtasks/cron/minio/k8s). See `TOOL_GROUPS` in `src/tools/policy.ts`. ### Policy Resolution @@ -1053,6 +1054,53 @@ Sync text-like objects from a MinIO prefix into nested memory namespaces. } ``` +### Kubernetes Tools + +#### `k8s.pods` + +List Kubernetes pods with status summary. + +#### `k8s.deployments` + +List Kubernetes deployments with replica readiness summary. + +#### `k8s.logs` + +Fetch recent logs for a pod. + +```json +{ + "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" + }, + "container": { + "type": "string", + "description": "Container name (optional)" + }, + "lines": { + "type": "number", + "description": "Tail line count" + }, + "since": { + "type": "string", + "description": "Lookback duration (e.g. 10m, 1h)" + } + }, + "required": ["pod"] + } +} +``` + ### Media Tools #### `media.send` diff --git a/docs/plans/state.json b/docs/plans/state.json index 5f88f62..489fb5a 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -118,6 +118,29 @@ ], "test_status": "pnpm test:run src/automation/minioSync.test.ts src/config/schema.test.ts + pnpm typecheck passing" }, + "k8s-homelab-awareness-tools": { + "status": "completed", + "date": "2026-02-16", + "updated": "2026-02-16", + "summary": "Added optional Kubernetes homelab-awareness tools (`k8s.pods`, `k8s.deployments`, `k8s.logs`) with `k8s.enabled` config gating, default/allowed namespace constraints, daemon registration, tool-policy group support (`group:k8s`), docs, and tests.", + "files_modified": [ + "src/tools/builtin/k8s.ts", + "src/tools/builtin/k8s.test.ts", + "src/tools/builtin/index.ts", + "src/tools/index.ts", + "src/daemon/index.ts", + "src/config/schema.ts", + "src/config/schema.test.ts", + "src/config/index.ts", + "src/tools/policy.ts", + "src/tools/policy.test.ts", + "config/default.yaml", + "README.md", + "docs/api/TOOLS.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/tools/builtin/k8s.test.ts src/tools/policy.test.ts src/config/schema.test.ts + pnpm typecheck passing" + }, "backup-session-summary-audit-trail": { "status": "completed", "date": "2026-02-16", diff --git a/src/config/index.ts b/src/config/index.ts index 82ac0ce..ca2c994 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -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'; diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 75f45ec..e9085c0 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -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] }, diff --git a/src/config/schema.ts b/src/config/schema.ts index 887da20..0836e76 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -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; export type AudioConfig = z.infer; export type ProcessConfig = z.infer; export type BrowserConfig = z.infer; +export type K8sConfig = z.infer; export type DiscordConfig = z.infer; export type SlackConfig = z.infer; export type WhatsAppConfig = z.infer; diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 4453010..8cdb404 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -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 }); diff --git a/src/tools/builtin/index.ts b/src/tools/builtin/index.ts index 2b1399a..03463a4 100644 --- a/src/tools/builtin/index.ts +++ b/src/tools/builtin/index.ts @@ -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'; diff --git a/src/tools/builtin/k8s.test.ts b/src/tools/builtin/k8s.test.ts new file mode 100644 index 0000000..8383498 --- /dev/null +++ b/src/tools/builtin/k8s.test.ts @@ -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 { + 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(); + }); +}); diff --git a/src/tools/builtin/k8s.ts b/src/tools/builtin/k8s.ts new file mode 100644 index 0000000..d2a0a31 --- /dev/null +++ b/src/tools/builtin/k8s.ts @@ -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): string | undefined { + return argsNamespace ?? cfg.default_namespace; +} + +function assertAllowedNamespace(namespace: string | undefined, cfg: NonNullable): 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(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, 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 => { + 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 => { + 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 => { + 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]; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index e688c4a..cbe9fc0 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -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'; diff --git a/src/tools/policy.test.ts b/src/tools/policy.test.ts index 100debf..90e3321 100644 --- a/src/tools/policy.test.ts +++ b/src/tools/policy.test.ts @@ -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', diff --git a/src/tools/policy.ts b/src/tools/policy.ts index c55cecc..a4ab8ee 100644 --- a/src/tools/policy.ts +++ b/src/tools/policy.ts @@ -42,6 +42,9 @@ const PROFILE_TOOLS: Record> = { '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> = { '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 = { '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. */