grafana, tautulli, nextcloud, nodered: full nox modules (ADR 0044/0046)

grafana: status/datasources/dashboards/alerts tools, emits alert.firing.
tautulli: activity/history/stats tools, emits watch.recorded. nextcloud:
users/shares/apps/occ tools (occ via docker exec, shares over OCS), emits
user.created/share.created. nodered: flows/nodes/deploy tools, emits
flows.deployed inline from the deploy tool. All typecheck; manifests parse.
This commit is contained in:
2026-09-04 02:43:20 +02:00
parent 1e2a849b90
commit eccfc70991
23 changed files with 897 additions and 2 deletions
+93
View File
@@ -0,0 +1,93 @@
// Nextcloud's client — nextcloud's own code, living in the module (novox/hq ADR 0044). Both this
// module's tools and its events entrypoint import it, and nothing outside nextcloud does.
//
// Nextcloud is administered two ways, and this client speaks both:
// - occ, its admin CLI, is a PHP script inside the container runnable only as the web user. We
// reach it with `docker exec`, the same side channel an operator would use by hand — turned
// into something the mesh can call. Users and apps come from here.
// - the OCS Sharing API answers over HTTP with the admin credentials. Shares come from here,
// because occ has no version-stable "list every share" across the releases we run.
import { execFileSync } from "node:child_process";
export interface NextcloudUser {
uid: string;
displayName: string;
}
export interface NextcloudShare {
/** The OCS share id — the stable identity a new share is diffed on. */
id: string;
path: string;
shareType: number;
shareWith?: string;
owner: string;
}
export class NextcloudClient {
constructor(
private readonly container: string,
private readonly ocsUrl: string,
private readonly adminUser: string,
private readonly adminPassword: string,
) {}
/**
* Build from the module's resolved environment. occ needs only the container name (default
* "nextcloud"); the OCS surface needs the admin password the module keeps as its own secret
* (MESH_NEXTCLOUD_ADMIN_PASSWORD, user MESH_NEXTCLOUD_ADMIN_USER default admin, URL the local
* container). The admin password is treated as the "configured for mesh administration" signal:
* throws without it, and the module then contributes nothing rather than failing.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): NextcloudClient {
const container = env.MESH_NEXTCLOUD_CONTAINER ?? "nextcloud";
const ocsUrl = env.MESH_NEXTCLOUD_URL ?? `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`;
const adminUser = env.MESH_NEXTCLOUD_ADMIN_USER ?? "admin";
const adminPassword = env.MESH_NEXTCLOUD_ADMIN_PASSWORD;
if (!adminPassword) throw new Error("no Nextcloud admin password — set MESH_NEXTCLOUD_ADMIN_PASSWORD");
return new NextcloudClient(container, ocsUrl.replace(/\/$/, ""), adminUser, adminPassword);
}
/** Run occ inside the container as the web user, returning its stdout, throwing its own message. */
occ(args: string[]): string {
try {
return execFileSync("docker", ["exec", "-u", "www-data", this.container, "php", "occ", ...args], {
encoding: "utf8", timeout: 60_000,
}).trim();
} catch (err: any) {
const detail = String(err?.stderr ?? err?.stdout ?? err?.message ?? "").trim();
throw new Error(detail || `occ produced no output — is the ${this.container} container running?`);
}
}
listUsers(): NextcloudUser[] {
// user:list --output=json answers an object of uid → display name.
const raw = this.occ(["user:list", "--output=json"]);
const map = JSON.parse(raw || "{}") as Record<string, string>;
return Object.entries(map).map(([uid, displayName]) => ({ uid, displayName }));
}
listApps(): { enabled: string[]; disabled: string[] } {
const raw = this.occ(["app:list", "--output=json"]);
const parsed = JSON.parse(raw || "{}") as { enabled?: Record<string, unknown>; disabled?: Record<string, unknown> };
return { enabled: Object.keys(parsed.enabled ?? {}), disabled: Object.keys(parsed.disabled ?? {}) };
}
/** List every share, over the OCS Sharing API with the admin credentials. */
async listShares(): Promise<NextcloudShare[]> {
const auth = Buffer.from(`${this.adminUser}:${this.adminPassword}`).toString("base64");
const res = await fetch(
`${this.ocsUrl}/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json`,
{ headers: { Authorization: `Basic ${auth}`, "OCS-APIRequest": "true", Accept: "application/json" } },
);
if (!res.ok) throw new Error(`Nextcloud OCS shares: ${res.status} ${await res.text()}`);
const rows = ((await res.json())?.ocs?.data ?? []) as any[];
return rows.map((s) => ({
id: String(s.id),
path: s.path ?? "",
shareType: Number(s.share_type ?? -1),
shareWith: s.share_with || undefined,
owner: s.uid_owner ?? "unknown",
}));
}
}
+57
View File
@@ -0,0 +1,57 @@
// nextcloud's events. The tool runtime imports this once the broker is bound. It watches the user
// list and the share list and announces new arrivals.
//
// Emits (novox/hq ADR 0046/0047):
// module.nextcloud.user.created — a user account appeared (occ user:list)
// module.nextcloud.share.created — a share appeared (OCS shares)
//
// Both are diffed and primed silently on the first look, so a restart does not re-announce every
// existing user and share as freshly created.
import { emit } from "@novox/mesh-sdk/events";
import { NextcloudClient } from "./client.js";
// Constructed lazily so an unconfigured node (no admin password) loads this entrypoint without
// crashing the events host — it simply watches nothing.
let nextcloud: NextcloudClient | undefined;
try {
nextcloud = NextcloudClient.fromEnv();
} catch (err) {
console.log(`[nextcloud] not configured, not watching: ${err}`);
}
const knownUsers = new Set<string>();
let usersPrimed = false;
async function pollUsers(client: NextcloudClient): Promise<void> {
const users = client.listUsers();
for (const u of users) {
if (knownUsers.has(u.uid)) continue;
if (usersPrimed) await emit("module.nextcloud.user.created", { uid: u.uid, displayName: u.displayName });
knownUsers.add(u.uid);
}
usersPrimed = true;
}
const knownShares = new Set<string>();
let sharesPrimed = false;
async function pollShares(client: NextcloudClient): Promise<void> {
const shares = await client.listShares();
for (const s of shares) {
if (knownShares.has(s.id)) continue;
if (sharesPrimed) await emit("module.nextcloud.share.created", { id: s.id, path: s.path, shareType: s.shareType, shareWith: s.shareWith, owner: s.owner });
knownShares.add(s.id);
}
sharesPrimed = true;
}
if (nextcloud) {
const client = nextcloud;
const tick = (fn: (c: NextcloudClient) => Promise<void>): void => {
const run = (): void => void fn(client).catch((err) => console.error(`[nextcloud] ${err}`));
setInterval(run, 60_000);
run();
};
tick(pollUsers);
tick(pollShares);
console.log("[nextcloud] watching users and shares");
}
+6 -1
View File
@@ -21,8 +21,13 @@
"postgres-database": "/var/lib/nextcloud-module/database.secret",
"s3-bucket": "/var/lib/nextcloud-module/store.secret"
},
"emits": [
"module.nextcloud.user.created",
"module.nextcloud.share.created"
],
"own-secrets": {
"admin": "/var/lib/nextcloud-module/admin.secret"
"admin": "/var/lib/nextcloud-module/admin.secret",
"broker": "/var/lib/nextcloud-module/broker"
},
"capabilities": [
"container-runtime"
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-nextcloud",
"version": "0.1.0",
"description": "nextcloud — file sync and share. Its client, tools and events live here (novox/hq ADR 0044).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+57
View File
@@ -0,0 +1,57 @@
// nextcloud's tools — importing nextcloud's own client (novox/hq ADR 0044). occ runs inside the
// container; shares come over OCS. They return structured data; the mesh serves them through the
// sdk's tool harness.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { NextcloudClient } from "../client.js";
export function getNextcloudTools(nextcloud: NextcloudClient): ToolDefinition[] {
return [
{
name: "nextcloud_users",
description: "List Nextcloud user accounts — uid and display name — via occ.",
input: {},
run: async () => {
const users = nextcloud.listUsers();
return { count: users.length, users };
},
},
{
name: "nextcloud_shares",
description: "List Nextcloud shares — path, type, who it is shared with — via the OCS API.",
input: {},
run: async () => {
const shares = await nextcloud.listShares();
return { count: shares.length, shares };
},
},
{
name: "nextcloud_apps",
description: "List Nextcloud apps, split into enabled and disabled, via occ.",
input: {},
run: async () => nextcloud.listApps(),
},
{
name: "nextcloud_occ",
description:
"Run an arbitrary occ admin command, e.g. status, 'config:system:get trusted_domains', " +
"user:list. occ is Nextcloud's CLI inside the container, run as the web user.",
input: { args: { type: "array", description: 'occ arguments, e.g. ["config:system:get","trusted_domains"]' } },
run: async (args) => {
const occArgs = (args.args ?? []) as unknown[];
if (!Array.isArray(occArgs) || occArgs.length === 0) throw new Error('args must be a non-empty array, e.g. ["status"]');
return { output: nextcloud.occ(occArgs.map(String)) || "(no output)" };
},
},
];
}
// The tools exist only when the admin password can be resolved; without it, nextcloud contributes
// none rather than failing the whole tool runtime.
registerModuleTools("nextcloud", (env) => {
try {
return getNextcloudTools(NextcloudClient.fromEnv(env));
} catch {
return [];
}
});
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "tools/index.ts"]
}