diff --git a/config/fleet.json b/config/fleet.json index c8fdf3d..73f3023 100644 --- a/config/fleet.json +++ b/config/fleet.json @@ -80,10 +80,10 @@ ], "configuredAgents": [ "pve-direct", - "truenas-direct", + "truenas-admin", "panda-direct" ], - "diagram": "taskboard direct SSH\n -> pve : built-in Proxmox overview\n -> truenas : built-in storage overview\n -> panda : built-in SSH add-on overview\n\nEach direct task\n -> ssh safe built-in command\n -> capture stdout/stderr\n -> task callback -> completed result\n", + "diagram": "taskboard direct SSH\n -> pve : built-in Proxmox overview\n -> truenas : dedicated truenas-admin audit actions\n -> panda : built-in SSH add-on overview\n\nEach direct task\n -> ssh safe built-in command or host-specific builtin audit\n -> capture stdout/stderr plus repo dependency correlation\n -> task callback -> completed/review result\n", "notes": [ "Direct targets are for safe built-in actions, not arbitrary remote shell execution from the UI.", "Completion state is written through the same callback pipeline used by remote agent runtimes." @@ -188,37 +188,45 @@ } }, { - "slug": "truenas-direct", - "assignmentKey": "truenas-direct", - "aliases": ["truenas-direct", "TrueNAS Direct", "truenas"], - "name": "TrueNAS Direct", + "slug": "truenas-admin", + "assignmentKey": "truenas-admin", + "aliases": ["truenas-admin", "truenas-direct", "TrueNAS Admin", "TrueNAS Direct", "truenas"], + "name": "TrueNAS Admin", "host": "truenas", - "role": "Direct storage checks over SSH", + "role": "Dedicated storage and dataset audit agent for the TrueNAS host", "runtimePath": "ssh://christopher@192.168.50.12:22", "configPath": null, "emoji": "T", "channels": [ { "label": "SSH", "value": "christopher@192.168.50.12:22" }, - { "label": "Actions", "value": "storage-overview" } + { "label": "Actions", "value": "dataset-audit, storage-overview" } ], "tools": ["ssh", "zfs", "systemctl", "midclt"], "capabilities": [ + "Build dataset dependency matrices from live storage plus repo configuration", "Verify storage datasets", "Check docker service state", "Report host identity and storage status" ], "files": [], "notes": [ - "Runs safe read-only storage checks.", - "Does not modify datasets or apps." + "Runs safe read-only storage and dependency audit checks.", + "Does not delete datasets directly; cleanup stays review-gated." ], "dispatch": { "method": "direct-ssh", "hostname": "192.168.50.12", "user": "christopher", "port": 22, - "defaultAction": "storage-overview", + "defaultAction": "dataset-audit", "actions": [ + { + "key": "dataset-audit", + "title": "Dataset dependency audit", + "description": "Correlate live ZFS datasets with active homelab dependency signals and produce cleanup review candidates.", + "command": "builtin:truenas-dataset-audit", + "successSummary": "TrueNAS dataset dependency audit completed" + }, { "key": "storage-overview", "title": "Storage overview", diff --git a/config/task-templates.json b/config/task-templates.json index d039352..351ee79 100644 --- a/config/task-templates.json +++ b/config/task-templates.json @@ -71,10 +71,10 @@ }, { "key": "direct-truenas-check", - "title": "TrueNAS direct verification", - "summary": "Run the built-in storage overview action through the direct SSH target.", + "title": "TrueNAS dataset audit", + "summary": "Run the dedicated TrueNAS dataset dependency audit through the host-specific admin agent.", "family": "direct", - "tags": ["host-ops", "storage-check", "action:storage-overview"], + "tags": ["host-ops", "storage-check", "dataset-audit", "action:dataset-audit"], "defaults": { "priority": "High", "dispatchMethod": "direct-ssh", diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 101e2a6..08af8a9 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -37,13 +37,14 @@ - typed direct target definitions in `config/fleet.json` - safe built-in host actions for `pve`, `truenas`, and `panda` - completion written through the callback pipeline + - dedicated `truenas-admin` audit action that correlates live ZFS datasets with repo dependency signals ## Verified Live - `grizzley` ZeroClaw webhook dispatch from taskboard - `ice` ZeroClaw webhook dispatch from taskboard - OpenClaw swarm queue creation and host worktree creation on `ubuntu` -- direct SSH host actions can now be dispatched for `pve`, `truenas`, and `panda` +- direct SSH host actions can now be dispatched for `pve`, `truenas-admin`, and `panda` ## Current Limits @@ -51,3 +52,4 @@ - ZeroClaw acknowledgements and completions are still operator-driven; remote runtimes do not push completion state back yet. - The board records remote webhook responses, but not structured per-step execution output from the agents. - Direct targets are intentionally restricted to configured safe actions and do not expose arbitrary shell execution in the UI. +- `truenas-admin` remains review-gated for cleanup decisions; the taskboard produces audit results and candidate datasets, not direct deletion. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a854ba9..04b24ed 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -14,7 +14,7 @@ 3. Expand direct host operations beyond the first safe action set. - add more read-only Proxmox actions - - add richer TrueNAS storage and service checks + - add richer TrueNAS storage, share, and snapshot checks beyond the dataset audit - add more Home Assistant supervisor and add-on checks 4. Add operator controls for swarm execution. diff --git a/lib/dispatch.ts b/lib/dispatch.ts index f2f68f7..45ff79d 100644 --- a/lib/dispatch.ts +++ b/lib/dispatch.ts @@ -79,6 +79,346 @@ function truncateOutput(output: string, maxLength = 4000) { return `${trimmed.slice(0, maxLength - 15)}\n...[truncated]`; } +type TrueNasDataset = { + name: string; + used: string; + avail: string; + mountpoint: string; +}; + +type DatasetSignal = { + source: string; + dataset: string; + signalType: "active" | "legacy"; + matchedText: string; +}; + +const TRUENAS_SIGNAL_PATTERNS: Array<{ + dataset: string; + patterns: string[]; + signalType: "active" | "legacy"; +}> = [ + { + dataset: "TrueNAS/NetworkMediaShare", + patterns: [ + "/mnt/truenas/mediadata", + "/mnt/TrueNAS/NetworkMediaShare", + "/mnt/TrueNAS/NetworkMediaShare/mediadata", + ], + signalType: "active", + }, + { + dataset: "RPiPool/PersonalMediaLibrary", + patterns: [ + "/mnt/PersonalMediaLibrary", + "/mnt/RPiPool/PersonalMediaLibrary", + ], + signalType: "active", + }, + { + dataset: "TrueNAS/backups", + patterns: [ + "/mnt/truenas-backup", + "/mnt/TrueNAS/backups", + ], + signalType: "active", + }, + { + dataset: "TrueNAS/container-config", + patterns: ["/mnt/TrueNAS/container-config"], + signalType: "active", + }, + { + dataset: "TrueNAS/databases", + patterns: ["/mnt/TrueNAS/databases"], + signalType: "active", + }, + { + dataset: "TrueNAS/homelab/databases", + patterns: ["/mnt/TrueNAS/homelab/databases"], + signalType: "active", + }, + { + dataset: "TrueNAS/homelab/hosts/ubuntu/docker-data", + patterns: ["/mnt/TrueNAS/homelab/hosts/ubuntu/docker-data"], + signalType: "active", + }, + { + dataset: "TrueNAS/homelab/hosts/grizzley/docker-data", + patterns: ["/mnt/TrueNAS/homelab/hosts/grizzley/docker-data"], + signalType: "active", + }, + { + dataset: "TrueNAS/homelab/hosts/ice/docker-data", + patterns: ["/mnt/TrueNAS/homelab/hosts/ice/docker-data"], + signalType: "active", + }, + { + dataset: "TrueNAS/RPiPool-backup", + patterns: ["/mnt/TrueNAS/RPiPool-backup"], + signalType: "legacy", + }, + { + dataset: "TrueNAS/PersonalMediaLibraryBackup", + patterns: ["/mnt/TrueNAS/PersonalMediaLibraryBackup"], + signalType: "legacy", + }, + { + dataset: "TrueNAS/TimeMachine", + patterns: ["/mnt/TrueNAS/TimeMachine", "TimeMachine"], + signalType: "legacy", + }, + { + dataset: "TrueNAS/UserShares", + patterns: ["/mnt/TrueNAS/UserShares", "UserShares"], + signalType: "legacy", + }, + { + dataset: "TrueNAS/UserShares/RedVelvet", + patterns: ["/mnt/TrueNAS/UserShares/RedVelvet", "TrueNAS/UserShares/RedVelvet"], + signalType: "legacy", + }, + { + dataset: "TrueNAS/UserShares/Vanilla", + patterns: ["/mnt/TrueNAS/UserShares/Vanilla", "TrueNAS/UserShares/Vanilla"], + signalType: "legacy", + }, + { + dataset: "TrueNAS/traefik-certs", + patterns: ["/mnt/truenas/traefik-certs", "/mnt/TrueNAS/traefik-certs"], + signalType: "active", + }, +]; + +const ACTIVE_TRUENAS_SCAN_PATHS = [ + "homelab/ubuntu", + "homelab/grizzley", + "homelab/truenas/AGENTS.md", + "homelab/AGENTS.md", + "homelab/catalog", + "ansible/playbooks", +]; + +const LEGACY_TRUENAS_SCAN_PATHS = [ + "homelab/inventory/truenas.json", + "homelab/proxmox/truenas", + "obsidian-vault/homelab", +]; + +function sshConnectionArgs(host: string, user: string, port: number) { + return [ + "-F", + "/dev/null", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + "-o", + "ConnectTimeout=15", + "-o", + "IdentitiesOnly=yes", + "-o", + "UserKnownHostsFile=/tmp/taskboard_known_hosts", + "-i", + DIRECT_SSH_KEY_PATH, + "-p", + String(port), + `${user}@${host}`, + ]; +} + +function existingHomelabRoots() { + const candidates = [ + process.env.HOMELAB_REPO_ROOT, + "/home/bear/homelabagentroot", + "/home/christopher/opencode-home", + ].filter((entry): entry is string => Boolean(entry)); + + return [...new Set(candidates)].filter((candidate) => fs.existsSync(candidate)); +} + +function collectTextFiles(targetPath: string, collected: string[]) { + if (!fs.existsSync(targetPath)) { + return; + } + + const stat = fs.statSync(targetPath); + if (stat.isFile()) { + if (stat.size <= 1024 * 1024) { + collected.push(targetPath); + } + return; + } + + for (const entry of fs.readdirSync(targetPath, { withFileTypes: true })) { + if (entry.name.startsWith(".git") || entry.name === "node_modules" || entry.name === "code-server-ai") { + continue; + } + collectTextFiles(path.join(targetPath, entry.name), collected); + } +} + +function scanTrueNasSignals(repoRoot: string, relativePaths: string[], signalType: "active" | "legacy") { + const files: string[] = []; + for (const relativePath of relativePaths) { + collectTextFiles(path.join(repoRoot, relativePath), files); + } + + const signals: DatasetSignal[] = []; + for (const filePath of files) { + let content = ""; + try { + content = fs.readFileSync(filePath, "utf8"); + } catch { + continue; + } + + for (const mapping of TRUENAS_SIGNAL_PATTERNS.filter((entry) => entry.signalType === signalType)) { + const matchedPattern = mapping.patterns.find((pattern) => content.includes(pattern)); + if (matchedPattern) { + signals.push({ + source: filePath, + dataset: mapping.dataset, + signalType, + matchedText: matchedPattern, + }); + } + } + } + + return signals; +} + +function datasetHierarchyName(datasetName: string) { + return datasetName.replace(/\/+$/, ""); +} + +function summarizeSignals(signals: DatasetSignal[], dataset: string) { + return signals.filter((signal) => signal.dataset === dataset); +} + +async function runTrueNasDatasetAudit(taskId: number, host: string, user: string, port: number): Promise { + const task = await findTask(taskId); + if (!task) { + throw new Error("task_not_found"); + } + + const sshArgs = sshConnectionArgs(host, user, port); + const { stdout } = await execFileAsync( + "ssh", + [...sshArgs, "zfs list -H -o name,used,avail,mountpoint"], + { + timeout: DIRECT_SSH_TIMEOUT_MS, + maxBuffer: 1024 * 1024, + }, + ); + + const datasets = stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [name, used, avail, mountpoint] = line.split("\t"); + return { name, used, avail, mountpoint } satisfies TrueNasDataset; + }); + + const repoRoots = existingHomelabRoots(); + const activeSignals = repoRoots.flatMap((repoRoot) => + scanTrueNasSignals(repoRoot, ACTIVE_TRUENAS_SCAN_PATHS, "active"), + ); + const legacySignals = repoRoots.flatMap((repoRoot) => + scanTrueNasSignals(repoRoot, LEGACY_TRUENAS_SCAN_PATHS, "legacy"), + ); + + const childActiveMap = new Map(); + for (const dataset of datasets) { + const parentNames = dataset.name.split("/").map((_, index, parts) => parts.slice(0, index + 1).join("/")); + for (const parentName of parentNames.slice(0, -1)) { + childActiveMap.set(parentName, (childActiveMap.get(parentName) || 0) + 1); + } + } + + const activeDatasets = datasets + .map((dataset) => ({ + dataset, + activeRefs: summarizeSignals(activeSignals, dataset.name), + legacyRefs: summarizeSignals(legacySignals, dataset.name), + })) + .filter(({ activeRefs }) => activeRefs.length > 0); + + const reviewCandidates = datasets + .map((dataset) => ({ + dataset, + activeRefs: summarizeSignals(activeSignals, dataset.name), + legacyRefs: summarizeSignals(legacySignals, dataset.name), + hasActiveChild: + datasets.some( + (candidate) => + candidate.name !== dataset.name && + datasetHierarchyName(candidate.name).startsWith(`${datasetHierarchyName(dataset.name)}/`) && + summarizeSignals(activeSignals, candidate.name).length > 0, + ), + })) + .filter(({ dataset, activeRefs, hasActiveChild }) => { + if (dataset.name === "TrueNAS" || dataset.name === "RPiPool" || dataset.name.includes("/.system")) { + return false; + } + return activeRefs.length === 0 && !hasActiveChild; + }); + + const detailSections = [ + `Task: #${task.id} ${task.title}`, + "", + "Active dependency signals", + ...( + activeDatasets.length > 0 + ? activeDatasets.map(({ dataset, activeRefs }) => + `- ${dataset.name} (${dataset.used}, mount ${dataset.mountpoint}) <- ${activeRefs + .slice(0, 4) + .map((ref) => path.relative(repoRoots[0] || process.cwd(), ref.source)) + .join(", ")}`, + ) + : ["- none detected"] + ), + "", + "Review candidates with no active dependency signal", + ...( + reviewCandidates.length > 0 + ? reviewCandidates.map(({ dataset, legacyRefs }) => + `- ${dataset.name} (${dataset.used}, mount ${dataset.mountpoint})${ + legacyRefs.length > 0 ? ` [legacy refs: ${legacyRefs.length}]` : "" + }`, + ) + : ["- none"] + ), + "", + "Legacy-only references", + ...( + legacySignals.length > 0 + ? legacySignals.map((signal) => `- ${signal.dataset} <- ${path.relative(repoRoots[0] || process.cwd(), signal.source)}`) + : ["- none"] + ), + "", + "Live datasets", + ...datasets.map((dataset) => `- ${dataset.name} | used ${dataset.used} | mount ${dataset.mountpoint}`), + ]; + + return { + state: "completed" as const, + summary: "TrueNAS dataset dependency audit completed", + detail: truncateOutput(detailSections.join("\n"), 12000), + callback: { + status: "Review", + dispatch_state: "completed", + summary: "TrueNAS dataset dependency audit completed", + detail: truncateOutput(detailSections.join("\n"), 12000), + completed_by: "direct-ssh:truenas-audit", + last_error: null, + last_dispatch_at: new Date().toISOString(), + }, + }; +} + async function dispatchOpenClawTask(taskId: number): Promise { const task = await findTask(taskId); if (!task) { @@ -236,24 +576,21 @@ async function dispatchDirectTask(taskId: number): Promise { throw new Error(`unsupported_direct_action:${actionKey}`); } + if (action.command === "builtin:truenas-dataset-audit") { + return runTrueNasDatasetAudit( + taskId, + directAgent.dispatch.hostname, + directAgent.dispatch.user, + directAgent.dispatch.port, + ); + } + const sshArgs = [ - "-F", - "/dev/null", - "-o", - "BatchMode=yes", - "-o", - "StrictHostKeyChecking=accept-new", - "-o", - "ConnectTimeout=15", - "-o", - "IdentitiesOnly=yes", - "-o", - "UserKnownHostsFile=/tmp/taskboard_known_hosts", - "-i", - DIRECT_SSH_KEY_PATH, - "-p", - String(directAgent.dispatch.port), - `${directAgent.dispatch.user}@${directAgent.dispatch.hostname}`, + ...sshConnectionArgs( + directAgent.dispatch.hostname, + directAgent.dispatch.user, + directAgent.dispatch.port, + ), action.command, ];