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:
@@ -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",
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user