Files
mesh-catalog/modules/grafana/client.ts
T
jochen eb42806379
mesh/merge-gate head eb4280637906 merged into trunk 53e51864969f — pass: builds baserow, gitea, grafana, home-assistant, icecast, influxdb, letta, mailu, n…
mesh/repo-check head eb4280637906 merged into trunk 53e51864969f — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/delivery delivered
mesh/delivery-group group fix/342-a-secret-is-never-a-setting delivered: every member is delivered
Make grafana prefer the vault's password, and let the secrets list only shrink (hq issue 342)
grafana's client logged in with a token setting ahead of the vault's admin password,
and its runtime config let a layer set token. The token setting is now ignored and
said, token is protected, and the vault's password comes first.

checks/secrets/known could gain a line hiding a newly unprotected key. merge-check.sh
now judges it against the trunk's copy at the merge base, and refuses an added line.
2026-10-13 07:43:08 +02:00

135 lines
5.1 KiB
TypeScript

// Grafana's API client — grafana's own code, living in the module (novox/hq ADR 0039). Ported from
// the shared hal sdk, where a change here rebuilt everything; here it rebuilds only grafana. Both
// this module's tools and its events entrypoint import it, and nothing outside grafana does.
import { readFileSync } from "node:fs";
export interface GrafanaHealth {
database: string;
version: string;
commit: string;
}
export interface GrafanaDatasource {
id: number;
uid: string;
name: string;
type: string;
url: string;
isDefault: boolean;
database?: string;
}
export interface GrafanaDashboard {
uid: string;
title: string;
url: string;
tags: string[];
folderTitle?: string;
}
export interface GrafanaAlert {
/** The rule name (labels.alertname), the stable identity a firing alert is diffed on. */
name: string;
/** Grafana unified-alerting state: "Normal" | "Pending" | "Alerting". */
state: string;
labels: Record<string, string>;
activeAt?: string;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { 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 {}; }
}
/**
* A password, token or API key is the vault's, never a setting (novox/hq issue 342): a value for one of
* them in the settings file is ignored, and that is said once — by its key, never with its value.
*/
const saidIgnored = new Set<string>();
function ignoreSecretSetting(cfg: Record<string, unknown>, key: string): void {
if (cfg[key] === undefined || saidIgnored.has(key)) return;
saidIgnored.add(key);
console.error(`the setting ${key} is ignored: it comes from the vault`);
}
export class GrafanaClient {
readonly baseUrl: string;
private readonly authHeader: string;
constructor(url: string, authHeader: string) {
this.baseUrl = url.replace(/\/$/, "");
this.authHeader = authHeader;
}
/**
* Build from the module's resolved environment. Auth is HTTP basic with the vault's admin password,
* which the runtime config carries as `password` (kept protected, so no setting replaces it) or
* MESH_GRAFANA_PASSWORD (user MESH_GRAFANA_USER, default admin); else a service-account/API token from
* the environment (MESH_GRAFANA_TOKEN, sent as Bearer). A `token` in the settings file is never used: a
* credential is the vault's, never a setting (hq issue 342). Throws when neither is configured — the
* module then contributes nothing rather than failing.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): GrafanaClient {
const cfg = meshConfig(env.MESH_GRAFANA_CONFIG_FILE);
const url = cfg.url ?? env.MESH_GRAFANA_URL ?? `http://127.0.0.1:${env.GRAFANA_PORT ?? "3000"}`;
ignoreSecretSetting(cfg, "token");
const password = cfg.password ?? env.MESH_GRAFANA_PASSWORD;
if (password) {
const user = cfg.user ?? env.MESH_GRAFANA_USER ?? "admin";
return new GrafanaClient(url, `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`);
}
const token = env.MESH_GRAFANA_TOKEN;
if (token) return new GrafanaClient(url, `Bearer ${token}`);
throw new Error("no Grafana auth — set MESH_GRAFANA_TOKEN or MESH_GRAFANA_PASSWORD");
}
private async get(path: string): Promise<any> {
const res = await fetch(`${this.baseUrl}${path}`, {
headers: { Authorization: this.authHeader, Accept: "application/json" },
});
if (!res.ok) throw new Error(`Grafana API ${path}: ${res.status} ${await res.text()}`);
return res.json();
}
async health(): Promise<GrafanaHealth> {
const h = await this.get("/api/health");
return { database: h.database ?? "unknown", version: h.version ?? "unknown", commit: h.commit ?? "unknown" };
}
async listDatasources(): Promise<GrafanaDatasource[]> {
const arr = (await this.get("/api/datasources")) as any[];
return arr.map((d) => ({
id: d.id, uid: d.uid, name: d.name, type: d.type, url: d.url,
isDefault: !!d.isDefault, database: d.database || undefined,
}));
}
async listDashboards(query?: string): Promise<GrafanaDashboard[]> {
const params = new URLSearchParams({ type: "dash-db" });
if (query) params.set("query", query);
const arr = (await this.get(`/api/search?${params.toString()}`)) as any[];
return arr.map((d) => ({
uid: d.uid, title: d.title, url: d.url, tags: d.tags ?? [], folderTitle: d.folderTitle || undefined,
}));
}
/**
* Active alert instances from unified alerting's Prometheus-compatible surface. Grafana without
* alerting configured answers this with an empty set (or a 404, surfaced by get) — callers treat
* "no alerts" and "no alerting" alike.
*/
async listAlerts(): Promise<GrafanaAlert[]> {
const data = (await this.get("/api/prometheus/grafana/api/v1/alerts")).data ?? {};
const alerts = (data.alerts ?? []) as any[];
return alerts.map((a) => ({
name: a.labels?.alertname ?? "unknown",
state: a.state ?? "unknown",
labels: a.labels ?? {},
activeAt: a.activeAt || undefined,
}));
}
}