From 4193eacec459cd64cece60ad6104dd23676359e2 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 00:41:30 +0200 Subject: [PATCH] Mark grafana's and home-assistant's soft requirements optional (hq issue 166, ADR 0330) grafana runs without influxdb and home-assistant without the Servarr apps or a broker, yet a mesh without them could not assign either. Their requirements are now optional: bound when a provider runs, absent otherwise, with nothing installed for them. grafana's data source and its credential are delivered only while influxdb-api is bound, into directories it mounts, so an absent file never becomes a directory docker made in its place. --- modules/grafana/module.json | 33 +++++++++++++---- modules/grafana/test/manifest.test.ts | 39 ++++++++++++++++++++ modules/home-assistant/module.json | 20 ++++++++-- modules/home-assistant/test/manifest.test.ts | 20 ++++++++++ 4 files changed, 101 insertions(+), 11 deletions(-) create mode 100644 modules/grafana/test/manifest.test.ts create mode 100644 modules/home-assistant/test/manifest.test.ts diff --git a/modules/grafana/module.json b/modules/grafana/module.json index caa9047d..9ac69bcc 100644 --- a/modules/grafana/module.json +++ b/modules/grafana/module.json @@ -71,20 +71,36 @@ "mode": "0644", "content": "GF_SERVER_ROOT_URL=https://${bound:route:name}\nGF_AUTH_GENERIC_OAUTH_ENABLED=true\nGF_AUTH_GENERIC_OAUTH_NAME=Keycloak\nGF_AUTH_GENERIC_OAUTH_CLIENT_ID=${bound:oidc-client:as}\nGF_AUTH_GENERIC_OAUTH_CLIENT_SECRET__FILE=/run/secrets/oidc-client\nGF_AUTH_GENERIC_OAUTH_SCOPES=openid email profile roles\nGF_AUTH_GENERIC_OAUTH_AUTH_URL=${bound:oidc-client:issuer}${bound:oidc-client:authorization-path}\nGF_AUTH_GENERIC_OAUTH_TOKEN_URL=${bound:oidc-client:issuer}${bound:oidc-client:token-path}\nGF_AUTH_GENERIC_OAUTH_API_URL=${bound:oidc-client:issuer}${bound:oidc-client:userinfo-path}\nGF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH=contains(roles[*], 'admin') && 'Admin' || contains(realm_access.roles[*], 'admin') && 'Admin' || 'Viewer'\nGF_AUTH_GENERIC_OAUTH_USE_PKCE=true\nGF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=true\nGF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_GRAFANA_ADMIN=true\n" }, + { + "id": "influxdb-datasources", + "type": "directory", + "path": "${dir:state}/datasources", + "mode": "0755", + "owner": "472:472" + }, + { + "id": "influxdb-credential", + "type": "directory", + "path": "${dir:state}/influxdb", + "mode": "0700", + "owner": "472:472" + }, { "id": "influxdb-secret", "type": "file", - "path": "${dir:state}/influxdb-api.secret", + "path": "${dir:influxdb-credential}/influxdb-api", "mode": "0400", "owner": "472:472", - "content": "${secret:influxdb-api}" + "content": "${secret:influxdb-api}", + "only-when-bound": "influxdb-api" }, { "id": "influxdb-datasource", "type": "file", - "path": "${dir:state}/datasource-influxdb.yaml", + "path": "${dir:influxdb-datasources}/mesh-influxdb.yaml", "mode": "0644", - "content": "apiVersion: 1\n# Written by the mesh from grafana's influxdb-api binding; grafana reads it at start. Its own name and\n# uid, so a data source somebody made in the UI is never overwritten, and read-only in the UI because\n# the mesh resets it. The password is read from the file the mesh delivers, never written here.\ndatasources:\n - name: InfluxDB (mesh)\n uid: mesh-influxdb-api\n type: influxdb\n access: proxy\n url: ${bound:influxdb-api:scheme}://${bound:influxdb-api:at}:${bound:influxdb-api:port}\n user: ${bound:influxdb-api:as}\n isDefault: false\n editable: false\n jsonData:\n dbName: ${bound:influxdb-api:bucket}\n httpMode: POST\n secureJsonData:\n password: $__file{/run/secrets/influxdb-api}\n" + "content": "apiVersion: 1\n# Written by the mesh from grafana's influxdb-api binding, only while it is bound (novox/hq ADR 0330):\n# without influxdb, grafana runs with no mesh data source. grafana reads it at start. Its own name and\n# uid, so a data source somebody made in the UI is never overwritten, and read-only in the UI because\n# the mesh resets it. The password is read from the file the mesh delivers, never written here.\ndatasources:\n - name: InfluxDB (mesh)\n uid: mesh-influxdb-api\n type: influxdb\n access: proxy\n url: ${bound:influxdb-api:scheme}://${bound:influxdb-api:at}:${bound:influxdb-api:port}\n user: ${bound:influxdb-api:as}\n isDefault: false\n editable: false\n jsonData:\n dbName: ${bound:influxdb-api:bucket}\n httpMode: POST\n secureJsonData:\n password: $__file{/run/secrets/influxdb/influxdb-api}\n", + "only-when-bound": "influxdb-api" }, { "id": "server", @@ -103,8 +119,8 @@ "${dir:data}:/var/lib/grafana", "${dir:state}/admin.secret:/run/secrets/admin:ro", "${dir:state}/oidc-client.secret:/run/secrets/oidc-client:ro", - "${dir:state}/influxdb-api.secret:/run/secrets/influxdb-api:ro", - "${dir:state}/datasource-influxdb.yaml:/etc/grafana/provisioning/datasources/mesh-influxdb.yaml:ro" + "${dir:influxdb-credential}:/run/secrets/influxdb:ro", + "${dir:influxdb-datasources}:/etc/grafana/provisioning/datasources:ro" ], "env": { "GF_SECURITY_ADMIN_PASSWORD__FILE": "/run/secrets/admin" @@ -131,7 +147,10 @@ "requires": [ "route", "oidc-client", - "influxdb-api" + { + "name": "influxdb-api", + "optional": true + } ], "contributes": { "route": { diff --git a/modules/grafana/test/manifest.test.ts b/modules/grafana/test/manifest.test.ts new file mode 100644 index 00000000..b0516070 --- /dev/null +++ b/modules/grafana/test/manifest.test.ts @@ -0,0 +1,39 @@ +// grafana runs without a data source, so its influxdb-api is optional (novox/hq ADR 0330, issue 166): +// a mesh without influxdb can assign grafana, and influxdb is never installed for it. While absent, the +// data source and its credential are not delivered, and grafana mounts their directories empty. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +type Requirement = string | { name: string; optional?: boolean }; +type Resource = { id: string; type: string; path?: string; content?: string; volumes?: string[]; "only-when-bound"?: string }; +const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8")) as { + requires: Requirement[]; + resources: Resource[]; +}; + +const optional = (name: string) => + manifest.requires.some((r) => typeof r !== "string" && r.name === name && r.optional === true); + +test("influxdb-api is optional, and route and oidc-client stay hard", () => { + assert.ok(optional("influxdb-api")); + assert.ok(manifest.requires.includes("route")); + assert.ok(manifest.requires.includes("oidc-client")); +}); + +test("every file naming influxdb-api is delivered only while it is bound", () => { + for (const r of manifest.resources) { + if (r.type === "file" && /\$\{(bound:influxdb-api:|secret:influxdb-api\})/.test(r.content ?? "")) { + assert.equal(r["only-when-bound"], "influxdb-api", `${r.id} names influxdb-api and is delivered while it is absent`); + } + } +}); + +test("the container mounts the data source's directories, never a file that may be absent", () => { + // A bind mount of a missing file makes a directory in its place, which the file could then never be written over. + const server = manifest.resources.find((r) => r.id === "server"); + for (const v of server?.volumes ?? []) { + assert.ok(!/influxdb.*\.(yaml|secret)|influxdb-api:/.test(v.split(":")[0]), `${v} mounts an only-when-bound file`); + } +}); diff --git a/modules/home-assistant/module.json b/modules/home-assistant/module.json index 6db00c87..d35ae18e 100644 --- a/modules/home-assistant/module.json +++ b/modules/home-assistant/module.json @@ -123,11 +123,23 @@ } ], "requires": [ - "lidarr-api", - "mqtt-topic", - "radarr-api", + { + "name": "lidarr-api", + "optional": true + }, + { + "name": "mqtt-topic", + "optional": true + }, + { + "name": "radarr-api", + "optional": true + }, "route", - "sonarr-api" + { + "name": "sonarr-api", + "optional": true + } ], "contributes": { "mqtt-topic": { diff --git a/modules/home-assistant/test/manifest.test.ts b/modules/home-assistant/test/manifest.test.ts new file mode 100644 index 00000000..fdff6bcd --- /dev/null +++ b/modules/home-assistant/test/manifest.test.ts @@ -0,0 +1,20 @@ +// Home Assistant runs without the Servarr apps and without a broker, so those requirements are optional +// (novox/hq ADR 0330, issue 166): a mesh without lidarr or an MQTT broker can assign home-assistant, and +// nothing is installed for it. Its provisions step already reads a binding that is not there as absent. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +type Requirement = string | { name: string; optional?: boolean }; +const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8")) as { requires: Requirement[] }; + +test("the Servarr APIs and mqtt-topic are optional, and route stays hard", () => { + for (const name of ["sonarr-api", "radarr-api", "lidarr-api", "mqtt-topic"]) { + assert.ok( + manifest.requires.some((r) => typeof r !== "string" && r.name === name && r.optional === true), + `${name} is not optional`, + ); + } + assert.ok(manifest.requires.includes("route")); +});