Merge pull request 'Mark grafana's and home-assistant's soft requirements optional (hq issue 166, ADR 0330)' (#259) from fix/166-optional-requirements into main

This commit was merged in pull request #259.
This commit is contained in:
2026-10-13 00:22:35 +00:00
4 changed files with 101 additions and 11 deletions
+26 -7
View File
@@ -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": {
+39
View File
@@ -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`);
}
});
+16 -4
View File
@@ -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": {
@@ -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"));
});