Nineteen modules gain a broker-bound runtime container that serves the module's tools under its own scoped account: bazarr, gitea, grafana, home-assistant, icecast, influxdb, jackett, keycloak, mailu, nextcloud, nodered, nzbget, ombi, photos, portainer, qbittorrent, searxng, tautulli, verdaccio. Config is the assignment's, not the manifest's (ADR 0051): each client's fromEnv overlays a settings-merged config file (MESH_<M>_CONFIG_FILE) over its env fallbacks, so URL and credentials come from `settings set`, with the URL defaulting to the server on the node. nextcloud and mailu also mount the docker socket for their exec-based tools. Proven in the mesh-lab: assigned-grafana green — settings deliver the URL and token, the runtime reads the merged config and serves grafana's tools under the scoped account, with nothing in the manifest. Two gaps this surfaced are filed as hq issues 008 (a provider runtime's seal key) and 009 (a settings change does not restart a container runtime). Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
103 lines
4.7 KiB
TypeScript
103 lines
4.7 KiB
TypeScript
// 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";
|
|
import { readFileSync } from "node:fs";
|
|
|
|
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;
|
|
}
|
|
|
|
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
|
|
function meshConfig(file?: string): Record<string, string> {
|
|
if (!file) return {};
|
|
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
|
catch { return {}; }
|
|
}
|
|
|
|
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 cfg = meshConfig(env.MESH_NEXTCLOUD_CONFIG_FILE);
|
|
const container = cfg.container ?? env.MESH_NEXTCLOUD_CONTAINER ?? "nextcloud";
|
|
const ocsUrl = cfg.url ?? env.MESH_NEXTCLOUD_URL ?? `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`;
|
|
const adminUser = cfg.user ?? env.MESH_NEXTCLOUD_ADMIN_USER ?? "admin";
|
|
const adminPassword = cfg.password ?? 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",
|
|
}));
|
|
}
|
|
}
|