[taskboard] add dedicated truenas admin agent
This commit is contained in:
371
lib/dispatch.ts
371
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<DispatchResult> {
|
||||
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<string, number>();
|
||||
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<DispatchResult> {
|
||||
const task = await findTask(taskId);
|
||||
if (!task) {
|
||||
@@ -236,24 +576,21 @@ async function dispatchDirectTask(taskId: number): Promise<DispatchResult> {
|
||||
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,
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user