Compare commits

...
12 Commits
Author SHA1 Message Date
mesh-admin a3d1c9b9ee Merge pull request 'An access has an id, and its mounts name it (issue 153)' (#198) from feat/153-an-access-has-an-id into main 2026-09-30 22:07:15 +00:00
jschoubben 684b9853ad An access has an id, and its mounts name it (issue 153)
Ten definitions name each access by id; the path stays as the default an assignment may replace,
and the host side of every mount says ${access:<id>}. Resolved with no placement, every definition
names exactly the paths it named before (TestPlacedDirectoriesKeepTheirPaths, extended). On an
adopted machine the assignment now says `accesses: {<id>: <path>}` and the mount follows.

Needs the controller that knows an access id (mesh-controller #176); the running one refuses the
field at registration.
2026-10-01 00:01:42 +02:00
mesh-admin 34ccc457fa Merge pull request 'The mesh's own files for a module are placed by the mesh, not the definition (issue 174)' (#197) from feat/the-mesh-places-its-own-files into main 2026-09-30 21:49:11 +00:00
jschoubben a724c0d82e Merge pull request 'A provider declares what it serves: mail's domain, the identity provider's issuer (issue 173)' (#196) from feat/a-provider-declares-what-it-serves into main
Reviewed-on: #196
2026-09-30 20:49:06 +00:00
jschoubben e3246fa11e A provider declares what it serves: mail's domain, the identity provider's issuer (issue 173)
Consumers read `${bound:smtp:domain}` and `${bound:oidc-client:issuer}`, and both keys reached
them only because a module's settings were laid over everything it served. Issue 173 stops that: a
setting overrides a key a served fact declares and adds none. So the two providers declare the keys
their consumers read, as the operator's value (`${setting:…}`, ADR 0155), and the setting that
already carries each fills it. Nothing a consumer reads changes.

Merges first: under the controller that still merges settings over served facts this is the same
value, and the controller that stops merging (mesh-controller, feat/the-mesh-places-its-own-files)
needs these declared before it rolls out.
2026-09-30 22:33:19 +02:00
jschoubben 48850ebf90 The mesh's own files for a module are placed by the mesh, not the definition (issue 174)
48 definitions stop naming /var/lib/mesh/<module>: the directory says `place: "mesh"`, the two
subdirectories beneath it (gitea's runtime state, anthropic-manager's output) state their path
beneath it, and every credential, binding, merged file and mount names it as ${dir:mesh-state}.
Resolved on the default root, every definition names exactly the paths it named before —
TestPlacedDirectoriesKeepTheirPaths in mesh-controller, run over both checkouts. Needs the
controller that knows the word (mesh-controller, same branch) one release ahead.
2026-09-30 22:29:28 +02:00
mesh-admin 8bc4b7c389 Merge pull request 'The media chain moves to novox/mesh-media-catalog; home-assistant and searxng keep their parts' (#195) from chore/media-chain-moves-out into main 2026-09-30 19:42:56 +00:00
jschoubben 7d721051f8 The media chain moves to novox/mesh-media-catalog; home-assistant and searxng keep their parts of that stack
jackett leaves: it is registered from novox/mesh-media-catalog with sonarr,
radarr, lidarr, bazarr, nzbget, qbittorrent, bookshelf, plex, tautulli,
kometa and ombi (PRs 145-168 consolidated there). What those branches
changed outside the chain stays here: home-assistant's provisions
(sonarr-api, radarr-api, mqtt-topic — from #147) and searxng's sidecar
dialling the port it was given (#154).
2026-09-30 21:42:42 +02:00
jschoubben b2df040896 Merge pull request 'distribution claims mesh-artifact-store' (#194) from feat/the-artifact-store-seat-is-named-for-its-scope into main
Reviewed-on: #194
2026-09-30 19:17:47 +00:00
jschoubben af069dd667 Merge pull request 'A definition names no host path for its own data' (#193) from feat/definitions-place-their-directories into main
Reviewed-on: #193
2026-09-30 19:17:00 +00:00
jschoubben 13e13734c5 distribution claims mesh-artifact-store, the seat's name for its scope (novox/hq ADR 0156) 2026-09-30 21:14:40 +02:00
jschoubben eed5e8958a A definition names no host path for its own data
Twenty-eight modules' data directories are placed: the root as place ".", a sub-directory named by
its id, and every host-side reference — binds, secrets, own secrets, grants, receives, file paths,
mounts, env-files — as ${dir:<id>}. Resolved on the default root every path is the one the manifest
named before, which the controller's TestPlacedDirectoriesKeepTheirPaths proves over both checkouts;
so no data moves and no machine sees a change. Five directories whose id is not their last segment
keep their path as a placement (novox/hq issue 119, ADR 0112, design 27).
2026-09-30 21:10:18 +02:00
76 changed files with 1828 additions and 903 deletions
+10 -11
View File
@@ -9,13 +9,13 @@
"model-access" "model-access"
], ],
"binds": { "binds": {
"model-access": "/var/lib/anthropic-consumer/model.json" "model-access": "${dir:state}/model.json"
}, },
"secrets": { "secrets": {
"model-access": "/var/lib/anthropic-consumer/access-token" "model-access": "${dir:state}/access-token"
}, },
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/anthropic-consumer/broker" "broker": "${dir:mesh-state}/broker"
}, },
"emits": [ "emits": [
"usage.session" "usage.session"
@@ -24,14 +24,14 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/anthropic-consumer", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/anthropic-consumer", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "claude-home", "id": "claude-home",
@@ -42,7 +42,6 @@
{ {
"id": "out", "id": "out",
"type": "directory", "type": "directory",
"path": "/var/lib/anthropic-consumer/out",
"mode": "0700" "mode": "0700"
}, },
{ {
@@ -56,7 +55,7 @@
"/app/modules/anthropic-consumer/dist/apply/index.js" "/app/modules/anthropic-consumer/dist/apply/index.js"
], ],
"volumes": [ "volumes": [
"/var/lib/anthropic-consumer:/run/state" "${dir:state}:/run/state"
], ],
"env": { "env": {
"MESH_MODEL_ACCESS_SECRET_FILE": "/run/state/access-token", "MESH_MODEL_ACCESS_SECRET_FILE": "/run/state/access-token",
@@ -77,8 +76,8 @@
"/app/modules/anthropic-consumer/dist/usage/index.js" "/app/modules/anthropic-consumer/dist/usage/index.js"
], ],
"volumes": [ "volumes": [
"/var/lib/mesh/anthropic-consumer/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/anthropic-consumer:/run/state" "${dir:state}:/run/state"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
+8 -8
View File
@@ -9,13 +9,13 @@
"model-access" "model-access"
], ],
"binds": { "binds": {
"model-access": "/var/lib/mesh/anthropic-manager/model.json" "model-access": "${dir:mesh-state}/model.json"
}, },
"secrets": { "secrets": {
"model-access": "/var/lib/mesh/anthropic-manager/refresh-token" "model-access": "${dir:mesh-state}/refresh-token"
}, },
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/anthropic-manager/broker" "broker": "${dir:mesh-state}/broker"
}, },
"emits": [ "emits": [
"usage.read" "usage.read"
@@ -24,13 +24,13 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/anthropic-manager", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "out", "id": "out",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/anthropic-manager/out", "path": "${dir:mesh-state}/out",
"mode": "0700" "mode": "0700"
}, },
{ {
@@ -45,8 +45,8 @@
"/app/modules/anthropic-manager/dist/refresh/index.js" "/app/modules/anthropic-manager/dist/refresh/index.js"
], ],
"volumes": [ "volumes": [
"/var/lib/mesh/anthropic-manager/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/anthropic-manager:/run/state" "${dir:mesh-state}:/run/state"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
+5 -6
View File
@@ -6,7 +6,7 @@
"**" "**"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/audit-logger/broker" "broker": "${dir:state}/broker"
}, },
"build": { "build": {
"on": [ "on": [
@@ -33,13 +33,12 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/audit-logger", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "trail", "id": "trail",
"type": "directory", "type": "directory",
"path": "/var/lib/audit-logger/trail",
"mode": "0700" "mode": "0700"
}, },
{ {
@@ -48,8 +47,8 @@
"name": "mesh-audit-logger", "name": "mesh-audit-logger",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/audit-logger/broker:/run/secrets/broker:ro", "${dir:state}/broker:/run/secrets/broker:ro",
"/var/lib/audit-logger/trail:/trail" "${dir:trail}:/trail"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
+6 -6
View File
@@ -26,7 +26,7 @@
}, },
"own-secrets": { "own-secrets": {
"admin": "${dir:state}/admin.secret", "admin": "${dir:state}/admin.secret",
"broker": "/var/lib/mesh/baserow/broker" "broker": "${dir:mesh-state}/broker"
}, },
"listens": [ "listens": [
{ {
@@ -41,8 +41,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/baserow", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
@@ -88,7 +88,7 @@
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/baserow/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n", "content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n",
"merge": "json" "merge": "json"
@@ -99,8 +99,8 @@
"name": "mesh-baserow", "name": "mesh-baserow",
"network": "baserow", "network": "baserow",
"volumes": [ "volumes": [
"/var/lib/mesh/baserow/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/baserow/config.json:/run/config/config.json:ro" "${dir:mesh-state}/config.json:/run/config/config.json:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
+17 -13
View File
@@ -8,8 +8,8 @@
"subtitle.downloaded" "subtitle.downloaded"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/bazarr/broker", "broker": "${dir:mesh-state}/broker",
"api-key": "/var/lib/mesh/bazarr/api-key" "api-key": "${dir:mesh-state}/api-key"
}, },
"listens": [ "listens": [
{ {
@@ -22,18 +22,22 @@
], ],
"accesses": [ "accesses": [
{ {
"id": "movies",
"path": "/services/media/movies", "path": "/services/media/movies",
"mode": "read-write" "mode": "read-write"
}, },
{ {
"id": "series",
"path": "/services/media/series", "path": "/services/media/series",
"mode": "read-write" "mode": "read-write"
}, },
{ {
"id": "anime",
"path": "/services/media/anime", "path": "/services/media/anime",
"mode": "read-write" "mode": "read-write"
}, },
{ {
"id": "downloads",
"path": "/services/media/downloads", "path": "/services/media/downloads",
"mode": "read" "mode": "read"
} }
@@ -42,8 +46,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/bazarr", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "config", "id": "config",
@@ -67,16 +71,16 @@
], ],
"volumes": [ "volumes": [
"/services/bazarr/config:/config", "/services/bazarr/config:/config",
"/services/media/movies:/movies", "${access:movies}:/movies",
"/services/media/series:/series", "${access:series}:/series",
"/services/media/anime:/anime", "${access:anime}:/anime",
"/services/media/downloads:/downloads" "${access:downloads}:/downloads"
] ]
}, },
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/bazarr/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
@@ -87,9 +91,9 @@
"name": "mesh-bazarr", "name": "mesh-bazarr",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/bazarr/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/bazarr/api-key:/run/secrets/api-key:ro", "${dir:mesh-state}/api-key:/run/secrets/api-key:ro",
"/var/lib/mesh/bazarr/config.json:/run/config/config.json:ro", "${dir:mesh-state}/config.json:/run/config/config.json:ro",
"/services/bazarr/config:/var/lib/bazarr/config:ro" "/services/bazarr/config:/var/lib/bazarr/config:ro"
], ],
"env": { "env": {
@@ -115,7 +119,7 @@
} }
}, },
"binds": { "binds": {
"route": "/var/lib/mesh/bazarr/route.json" "route": "${dir:mesh-state}/route.json"
}, },
"build": { "build": {
"on": [ "on": [
+9 -7
View File
@@ -11,7 +11,7 @@
], ],
"consumes": [], "consumes": [],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/bookshelf/broker" "broker": "${dir:mesh-state}/broker"
}, },
"listens": [ "listens": [
{ {
@@ -24,10 +24,12 @@
], ],
"accesses": [ "accesses": [
{ {
"id": "books",
"path": "/services/media/books", "path": "/services/media/books",
"mode": "read-write" "mode": "read-write"
}, },
{ {
"id": "downloads",
"path": "/services/media/downloads", "path": "/services/media/downloads",
"mode": "read-write" "mode": "read-write"
} }
@@ -36,8 +38,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/bookshelf", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "config", "id": "config",
@@ -61,8 +63,8 @@
], ],
"volumes": [ "volumes": [
"/services/bookshelf/config:/config", "/services/bookshelf/config:/config",
"/services/media/books:/books", "${access:books}:/books",
"/services/media/downloads:/downloads" "${access:downloads}:/downloads"
] ]
}, },
{ {
@@ -71,7 +73,7 @@
"name": "mesh-bookshelf", "name": "mesh-bookshelf",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/bookshelf/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/services/bookshelf/config:/var/lib/bookshelf/config:ro" "/services/bookshelf/config:/var/lib/bookshelf/config:ro"
], ],
"env": { "env": {
@@ -92,7 +94,7 @@
} }
}, },
"binds": { "binds": {
"route": "/var/lib/mesh/bookshelf/route.json" "route": "${dir:mesh-state}/route.json"
}, },
"build": { "build": {
"on": [ "on": [
+10 -11
View File
@@ -15,33 +15,32 @@
"npm-package-registry" "npm-package-registry"
], ],
"binds": { "binds": {
"npm-package-registry": "/var/lib/mesh/builder/package-registry.json" "npm-package-registry": "${dir:mesh-state}/package-registry.json"
}, },
"secrets": { "secrets": {
"npm-package-registry": "/var/lib/mesh/builder/package-registry.secret" "npm-package-registry": "${dir:mesh-state}/package-registry.secret"
}, },
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/builder/broker" "broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/builder", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "workspace", "id": "workspace",
"type": "directory", "type": "directory",
"path": "/var/lib/builder/workspace",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "builder-env", "id": "builder-env",
"type": "file", "type": "file",
"path": "/var/lib/mesh/builder/builder.env", "path": "${dir:mesh-state}/builder.env",
"mode": "0600", "mode": "0600",
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_WORKSPACE=/var/lib/builder/workspace\n" "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_WORKSPACE=${dir:workspace}\n"
}, },
{ {
"id": "server", "id": "server",
@@ -49,11 +48,11 @@
"name": "mesh-builder", "name": "mesh-builder",
"artifact": "server", "artifact": "server",
"env-file": [ "env-file": [
"/var/lib/mesh/builder/builder.env" "${dir:mesh-state}/builder.env"
], ],
"volumes": [ "volumes": [
"/var/lib/mesh/builder:/run/mesh:ro", "${dir:mesh-state}:/run/mesh:ro",
"/var/lib/builder/workspace:/var/lib/builder/workspace", "${dir:workspace}:${dir:workspace}",
"/var/run/docker.sock:/var/run/docker.sock" "/var/run/docker.sock:/var/run/docker.sock"
], ],
"restart-on": [ "restart-on": [
+13 -14
View File
@@ -12,14 +12,14 @@
"public-dns": {} "public-dns": {}
}, },
"grants": { "grants": {
"public-dns": "/var/lib/cloudflare-dns/grants" "public-dns": "${dir:grants}"
}, },
"receives": { "receives": {
"public-dns": "/var/lib/cloudflare-dns/grants/mesh.json" "public-dns": "${dir:grants}/mesh.json"
}, },
"own-secrets": { "own-secrets": {
"token": "/var/lib/cloudflare-dns/token", "token": "${dir:state}/token",
"broker": "/var/lib/mesh/cloudflare-dns/broker" "broker": "${dir:mesh-state}/broker"
}, },
"emits": [ "emits": [
"record.created", "record.created",
@@ -29,25 +29,24 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/cloudflare-dns", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/cloudflare-dns", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "grants", "id": "grants",
"type": "directory", "type": "directory",
"path": "/var/lib/cloudflare-dns/grants",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "config", "id": "config",
"type": "file", "type": "file",
"path": "/var/lib/cloudflare-dns/config.json", "path": "${dir:state}/config.json",
"merge": "json", "merge": "json",
"content": "{}", "content": "{}",
"mode": "0600" "mode": "0600"
@@ -58,10 +57,10 @@
"name": "mesh-cloudflare-dns", "name": "mesh-cloudflare-dns",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/cloudflare-dns/config.json:/run/config/config.json:ro", "${dir:state}/config.json:/run/config/config.json:ro",
"/var/lib/cloudflare-dns/grants:/grants", "${dir:grants}:/grants",
"/var/lib/cloudflare-dns/token:/run/secrets/token:ro", "${dir:state}/token:/run/secrets/token:ro",
"/var/lib/mesh/cloudflare-dns/broker:/run/secrets/broker:ro" "${dir:mesh-state}/broker:/run/secrets/broker:ro"
], ],
"env": { "env": {
"MESH_CLOUDFLARE_TOKEN_FILE": "/run/secrets/token", "MESH_CLOUDFLARE_TOKEN_FILE": "/run/secrets/token",
+10 -10
View File
@@ -3,26 +3,26 @@
"version": "1", "version": "1",
"slug": "confl", "slug": "confl",
"own-secrets": { "own-secrets": {
"token": "/var/lib/confluence/token", "token": "${dir:state}/token",
"broker": "/var/lib/mesh/confluence/broker" "broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/confluence", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/confluence", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "config", "id": "config",
"type": "file", "type": "file",
"path": "/var/lib/confluence/config.json", "path": "${dir:state}/config.json",
"merge": "json", "merge": "json",
"content": "{}", "content": "{}",
"mode": "0600" "mode": "0600"
@@ -33,9 +33,9 @@
"name": "mesh-runtime-confluence", "name": "mesh-runtime-confluence",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/confluence/config.json:/run/config/config.json:ro", "${dir:state}/config.json:/run/config/config.json:ro",
"/var/lib/confluence/token:/run/secrets/token:ro", "${dir:state}/token:/run/secrets/token:ro",
"/var/lib/mesh/confluence/broker:/run/secrets/broker:ro" "${dir:mesh-state}/broker:/run/secrets/broker:ro"
], ],
"env": { "env": {
"MESH_CONFLUENCE_TOKEN_FILE": "/run/secrets/token", "MESH_CONFLUENCE_TOKEN_FILE": "/run/secrets/token",
+7 -7
View File
@@ -15,11 +15,11 @@
} }
}, },
"binds": { "binds": {
"route": "/var/lib/de-spiegel/route.json" "route": "${dir:state}/route.json"
}, },
"own-secrets": { "own-secrets": {
"smtp-user": "/var/lib/de-spiegel/smtp-user.secret", "smtp-user": "${dir:state}/smtp-user.secret",
"smtp-pass": "/var/lib/de-spiegel/smtp-pass.secret" "smtp-pass": "${dir:state}/smtp-pass.secret"
}, },
"listens": [ "listens": [
{ {
@@ -34,13 +34,13 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/de-spiegel", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "server-env", "id": "server-env",
"type": "file", "type": "file",
"path": "/var/lib/de-spiegel/server.env", "path": "${dir:state}/server.env",
"mode": "0600", "mode": "0600",
"content": "SMTP_AUTH_USER=${secret:smtp-user}\nSMTP_AUTH_PASS=${secret:smtp-pass}\n" "content": "SMTP_AUTH_USER=${secret:smtp-user}\nSMTP_AUTH_PASS=${secret:smtp-pass}\n"
}, },
@@ -56,7 +56,7 @@
"image": "registry-api.novox.be/novox/de-spiegel@sha256:e144b72ce9c145870470d765343549f2c60211728cd118b9ff0e4029f36342ba", "image": "registry-api.novox.be/novox/de-spiegel@sha256:e144b72ce9c145870470d765343549f2c60211728cd118b9ff0e4029f36342ba",
"network": "de-spiegel", "network": "de-spiegel",
"env-file": [ "env-file": [
"/var/lib/de-spiegel/server.env" "${dir:state}/server.env"
], ],
"ports": [ "ports": [
"35621" "35621"
+1 -1
View File
@@ -9,7 +9,7 @@
], ],
"claims": [ "claims": [
{ {
"name": "the-artifact-store", "name": "mesh-artifact-store",
"scope": "mesh" "scope": "mesh"
} }
], ],
+3 -3
View File
@@ -12,7 +12,7 @@
"name.removed" "name.removed"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/dnsmasq/broker" "broker": "${dir:mesh-state}/broker"
}, },
"claims": [ "claims": [
{ {
@@ -42,8 +42,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/dnsmasq", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "package", "id": "package",
+8 -8
View File
@@ -86,19 +86,19 @@
} }
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/gitea/broker" "broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/gitea", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "runtime-state", "id": "runtime-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/gitea/state", "path": "${dir:mesh-state}/state",
"mode": "0700" "mode": "0700"
}, },
{ {
@@ -175,7 +175,7 @@
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/gitea/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
@@ -186,11 +186,11 @@
"name": "mesh-gitea", "name": "mesh-gitea",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/gitea/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/gitea/config.json:/run/config/config.json:ro", "${dir:mesh-state}/config.json:/run/config/config.json:ro",
"${dir:grants}:${dir:grants}:ro", "${dir:grants}:${dir:grants}:ro",
"${dir:state}/admin.secret:/run/secrets/admin:ro", "${dir:state}/admin.secret:/run/secrets/admin:ro",
"/var/lib/mesh/gitea/state:/run/state" "${dir:runtime-state}:/run/state"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
+10 -10
View File
@@ -2,26 +2,26 @@
"module": "gitlab", "module": "gitlab",
"version": "1", "version": "1",
"own-secrets": { "own-secrets": {
"token": "/var/lib/gitlab/token", "token": "${dir:state}/token",
"broker": "/var/lib/mesh/gitlab/broker" "broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/gitlab", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/gitlab", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "config", "id": "config",
"type": "file", "type": "file",
"path": "/var/lib/gitlab/config.json", "path": "${dir:state}/config.json",
"merge": "json", "merge": "json",
"content": "{}", "content": "{}",
"mode": "0600" "mode": "0600"
@@ -32,9 +32,9 @@
"name": "mesh-runtime-gitlab", "name": "mesh-runtime-gitlab",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/gitlab/config.json:/run/config/config.json:ro", "${dir:state}/config.json:/run/config/config.json:ro",
"/var/lib/gitlab/token:/run/secrets/token:ro", "${dir:state}/token:/run/secrets/token:ro",
"/var/lib/mesh/gitlab/broker:/run/secrets/broker:ro" "${dir:mesh-state}/broker:/run/secrets/broker:ro"
], ],
"env": { "env": {
"MESH_GITLAB_TOKEN_FILE": "/run/secrets/token", "MESH_GITLAB_TOKEN_FILE": "/run/secrets/token",
+9 -9
View File
@@ -5,8 +5,8 @@
"alert.firing" "alert.firing"
], ],
"own-secrets": { "own-secrets": {
"admin": "/var/lib/mesh/grafana/admin", "admin": "${dir:mesh-state}/admin",
"broker": "/var/lib/mesh/grafana/broker" "broker": "${dir:mesh-state}/broker"
}, },
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
@@ -24,8 +24,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/grafana", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
@@ -108,7 +108,7 @@
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/grafana/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{\n \"user\": \"admin\",\n \"password\": \"${secret:admin}\"\n}\n", "content": "{\n \"user\": \"admin\",\n \"password\": \"${secret:admin}\"\n}\n",
"merge": "json" "merge": "json"
@@ -119,8 +119,8 @@
"name": "mesh-grafana", "name": "mesh-grafana",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/grafana/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/grafana/config.json:/run/config/config.json:ro" "${dir:mesh-state}/config.json:/run/config/config.json:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
@@ -158,8 +158,8 @@
"influxdb-api": "${dir:state}/influxdb.json" "influxdb-api": "${dir:state}/influxdb.json"
}, },
"secrets": { "secrets": {
"oidc-client": "/var/lib/mesh/grafana/oidc-client", "oidc-client": "${dir:mesh-state}/oidc-client",
"influxdb-api": "/var/lib/mesh/grafana/influxdb-api" "influxdb-api": "${dir:mesh-state}/influxdb-api"
}, },
"build": { "build": {
"on": [ "on": [
+5 -5
View File
@@ -15,7 +15,7 @@
} }
}, },
"binds": { "binds": {
"route": "/var/lib/hello-web/route.json" "route": "${dir:state}/route.json"
}, },
"listens": [ "listens": [
{ {
@@ -30,13 +30,13 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/hello-web", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "page", "id": "page",
"type": "file", "type": "file",
"path": "/var/lib/hello-web/index.html", "path": "${dir:state}/index.html",
"mode": "0644", "mode": "0644",
"content": "hello from hello-web, routed by the mesh\n" "content": "hello from hello-web, routed by the mesh\n"
}, },
@@ -54,7 +54,7 @@
"8080" "8080"
], ],
"volumes": [ "volumes": [
"/var/lib/hello-web/index.html:/www/index.html:ro" "${dir:state}/index.html:/www/index.html:ro"
], ],
"args": [ "args": [
"sh", "sh",
+4 -1
View File
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/home-assistant WORKDIR /app/modules/home-assistant
COPY . . COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \ RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisions/hass.ts provisions/probe.ts provisions/connections.ts provisions/mesh.ts provisions/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE} FROM ${RUNTIME_BASE}
@@ -22,3 +22,6 @@ COPY --from=build /app/modules/home-assistant/dist /app/modules/home-assistant/d
# provider's provisioner runs its reconcile loop in the same process, with the broker connected — # provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled. # the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/home-assistant/dist/index.js,/app/modules/home-assistant/dist/tools/index.js ENV MESH_TOOL_MODULES=/app/modules/home-assistant/dist/index.js,/app/modules/home-assistant/dist/tools/index.js
# NOT dist/provisions/index.js: that is a step the host runs to completion, named by the
# `provisions` container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run
# inside the serving sidecar too, and exit it.
+100 -20
View File
@@ -9,8 +9,8 @@
"state.changed" "state.changed"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/home-assistant/broker", "broker": "${dir:mesh-state}/broker",
"token": "/var/lib/mesh/home-assistant/token" "token": "${dir:mesh-state}/token"
}, },
"listens": [ "listens": [
{ {
@@ -18,40 +18,63 @@
"port": 8123, "port": 8123,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the dashboard and the API" "why": "the dashboard, the API and the companion apps"
},
{
"name": "sonos-events",
"port": 1400,
"protocol": "tcp",
"from": "mesh",
"why": "the Sonos integration's event callback: speakers push their state changes here"
},
{
"name": "webrtc",
"port": 18555,
"protocol": "tcp",
"from": "mesh",
"why": "the bundled go2rtc's WebRTC port, which camera streams to a browser use"
} }
], ],
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/home-assistant", "mode": "0700",
"mode": "0700" "place": "mesh"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
}, },
{ {
"id": "config", "id": "config",
"type": "directory", "type": "directory",
"path": "/services/home-assistant/config", "mode": "0700"
"mode": "0700", },
"owner": "1000:1000" {
"id": "written",
"type": "directory",
"mode": "0700"
}, },
{ {
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "home-assistant", "name": "home-assistant",
"image": "ghcr.io/home-assistant/home-assistant@sha256:14931c6b13756317849f46da1d01b45937a1150db66c081cfe529d48215943fe", "image": "ghcr.io/home-assistant/home-assistant@sha256:d8922685169707fd91e8b9729902d975f06157d005e422874d201e0261dda196",
"network": "host", "network": "host",
"env": { "env": {
"TZ": "Etc/UTC" "TZ": "Etc/UTC"
}, },
"volumes": [ "volumes": [
"/services/home-assistant/config:/config" "${dir:config}:/config"
] ]
}, },
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/home-assistant/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
@@ -62,35 +85,92 @@
"name": "mesh-home-assistant", "name": "mesh-home-assistant",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/home-assistant/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/home-assistant/token:/run/secrets/token:ro", "${dir:mesh-state}/token:/run/secrets/token:ro",
"/var/lib/mesh/home-assistant/config.json:/run/config/config.json:ro", "${dir:mesh-state}/config.json:/run/config/config.json:ro"
"/services/home-assistant/config:/var/lib/home-assistant/config:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_HOMEASSISTANT_URL": "http://127.0.0.1:8123", "MESH_HOMEASSISTANT_URL": "http://127.0.0.1:${port:8123}",
"MESH_HOMEASSISTANT_TOKEN_FILE": "/run/secrets/token", "MESH_HOMEASSISTANT_TOKEN_FILE": "/run/secrets/token",
"MESH_HOMEASSISTANT_CONFIG_FILE": "/run/config/config.json", "MESH_HOMEASSISTANT_CONFIG_FILE": "/run/config/config.json"
"MESH_HOMEASSISTANT_CONFIG_DIR": "/var/lib/home-assistant/config"
}, },
"restart-on": [ "restart-on": [
"runtime-config" "runtime-config"
], ],
"artifact": "runtime" "artifact": "runtime"
},
{
"id": "provisions",
"type": "container",
"name": "mesh-home-assistant-provisions",
"network": "host",
"run-once": true,
"volumes": [
"${dir:mesh-state}/token:/run/secrets/token:ro",
"${dir:written}:/var/lib/home-assistant-provisions",
"${dir:state}/mqtt-topic.json:/run/provisions/mqtt-topic.json:ro",
"${dir:state}/mqtt-topic.secret:/run/provisions/mqtt-topic.secret:ro",
"${dir:state}/sonarr-api.json:/run/provisions/sonarr-api.json:ro",
"${dir:state}/sonarr-api.secret:/run/provisions/sonarr-api.secret:ro",
"${dir:state}/radarr-api.json:/run/provisions/radarr-api.json:ro",
"${dir:state}/radarr-api.secret:/run/provisions/radarr-api.secret:ro",
"${dir:state}/lidarr-api.json:/run/provisions/lidarr-api.json:ro",
"${dir:state}/lidarr-api.secret:/run/provisions/lidarr-api.secret:ro"
],
"env": {
"MESH_HOMEASSISTANT_URL": "http://127.0.0.1:${port:8123}",
"MESH_HOMEASSISTANT_TOKEN_FILE": "/run/secrets/token",
"MESH_PROVISIONS_DIR": "/run/provisions",
"MESH_WRITTEN_DIR": "/var/lib/home-assistant-provisions"
},
"args": [
"run",
"/app/modules/home-assistant/dist/provisions/index.js"
],
"restart-on": [
"bound-mqtt-topic",
"secret-mqtt-topic",
"bound-sonarr-api",
"secret-sonarr-api",
"bound-radarr-api",
"secret-radarr-api",
"bound-lidarr-api",
"secret-lidarr-api"
],
"artifact": "runtime"
} }
], ],
"requires": [ "requires": [
"route" "lidarr-api",
"mqtt-topic",
"radarr-api",
"route",
"sonarr-api"
], ],
"contributes": { "contributes": {
"mqtt-topic": {
"topics": [
"#"
]
},
"route": { "route": {
"label": "home-assistant", "label": "home-assistant",
"endpoint": "web" "endpoint": "web"
} }
}, },
"binds": { "binds": {
"route": "/var/lib/mesh/home-assistant/route.json" "route": "${dir:state}/route.json",
"mqtt-topic": "${dir:state}/mqtt-topic.json",
"sonarr-api": "${dir:state}/sonarr-api.json",
"radarr-api": "${dir:state}/radarr-api.json",
"lidarr-api": "${dir:state}/lidarr-api.json"
},
"secrets": {
"mqtt-topic": "${dir:state}/mqtt-topic.secret",
"sonarr-api": "${dir:state}/sonarr-api.secret",
"radarr-api": "${dir:state}/radarr-api.secret",
"lidarr-api": "${dir:state}/lidarr-api.secret"
}, },
"build": { "build": {
"on": [ "on": [
+6 -1
View File
@@ -1,9 +1,14 @@
{ {
"name": "@novox/module-home-assistant", "name": "@novox/module-home-assistant",
"version": "0.1.0", "version": "0.1.0",
"description": "home-assistant — home automation platform. Its API client, tools and events live here (novox/hq ADR 0039).", "description": "home-assistant \u2014 home automation platform. Its API client, tools and events live here (novox/hq ADR 0039).",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": {
"build": "tsc client.ts index.ts tools/index.ts provisions/hass.ts provisions/probe.ts provisions/connections.ts provisions/mesh.ts provisions/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"typecheck": "tsc -p tsconfig.json",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.0" "@novox/mesh-sdk": "^0.1.0"
}, },
@@ -0,0 +1,486 @@
// How home-assistant's provisions step brings Home Assistant's integrations in line with what the
// mesh bound: the MQTT integration to `mqtt-topic`, the Sonarr, Radarr and Lidarr integrations to
// `sonarr-api`, `radarr-api` and `lidarr-api`. Pure logic over two seams — Home Assistant's config
// flows (hass.ts) and the broker/apps — so it is tested against fakes (test/provisions.test.ts).
//
// The half that reads files and talks HTTP lives beside it (mesh.ts, hass.ts, probe.ts, index.ts).
import { createHash } from "node:crypto";
import type { Hass, SchemaField } from "./hass.js";
import type { Probe } from "./probe.js";
/** What the mesh wrote at `binds.<provision>` (the controller's binding document). */
export interface Binding {
provision?: string;
from?: string;
at?: string;
as?: string;
serves?: Record<string, unknown>;
}
/** How one provision came out. Never carries a credential. */
export type Outcome =
| { what: string; result: "unchanged"; note?: string }
| { what: string; result: "written"; fields: string[]; note?: string }
| { what: string; result: "equivalent"; note: string }
| { what: string; result: "refused"; problem: string };
/** A port the binding serves, or undefined when it names none usable. */
export function portOf(serves: Record<string, unknown> | undefined): number | undefined {
const port = Number(serves?.port);
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : undefined;
}
/** A host as it goes into a URL: an IPv6 literal bracketed. */
export function urlHost(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
/**
* What this step last wrote, per target, as a digest: the only way to know "already as the mesh
* says" for a credential Home Assistant will not show back. A sha256 over the target and the values,
* never the values; kept in the module's own placed directory.
*/
export interface Marks {
get(name: string): Promise<string | undefined>;
set(name: string, digest: string): Promise<void>;
}
export function digest(...parts: (string | number)[]): string {
return createHash("sha256").update(parts.map(String).join("\u0000")).digest("hex");
}
/** An error as text with the credential taken out, raw and URL-encoded. */
export function scrub(err: unknown, ...secrets: (string | undefined)[]): string {
let text = err instanceof Error ? err.message : String(err);
for (const s of secrets) {
if (!s) continue;
for (const form of new Set([s, encodeURIComponent(s)])) text = text.split(form).join("***");
}
return text;
}
/**
* What a form would submit if a person pressed "submit" without touching it: each field's
* suggested value (what Home Assistant pre-fills from the entry), else its default; a section's
* fields nested under its name. The step lays only the connection fields over this, so every other
* choice the entry carries is sent back exactly as Home Assistant showed it.
*/
export function formValues(schema: readonly SchemaField[] | null | undefined): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const field of schema ?? []) {
if (Array.isArray(field.schema)) {
out[field.name] = formValues(field.schema);
continue;
}
const suggested = field.description?.suggested_value;
if (suggested !== undefined && suggested !== null) out[field.name] = suggested;
else if (field.default !== undefined) out[field.name] = field.default;
}
return out;
}
/** Whether a form has a field of this name at its top level. */
export function hasField(schema: readonly SchemaField[] | null | undefined, name: string): boolean {
return (schema ?? []).some((f) => f.name === name);
}
// ---- MQTT ----
// Home Assistant's MQTT integration, pointed at the broker the mesh bound — `mqtt-topic`.
//
// **Why a step.** Home Assistant keeps its broker, login and password in its MQTT config entry
// (`.storage/core.config_entries`), not in a file the mesh could fill with `${bound:mqtt-topic:at}`.
// So this reads the binding and the pair credential and makes the entry say the same thing, through
// the MQTT integration's own reconfigure flow — the flow its "Reconfigure" button runs, which tests
// the connection itself and saves nothing it could not connect with.
//
// **Only the connection, and only when it differs.** Broker, port, username, password. The protocol
// version, client id, keepalive, TLS choices and discovery options the entry holds are sent back
// exactly as Home Assistant pre-filled them. Whether the password already matches cannot be read
// back (Home Assistant never shows a stored password), so the step keeps a digest of what it last
// wrote: equal broker/port/username and an equal digest is "already as the mesh says".
//
// **Nothing loses its connection without someone seeing it.** Before Home Assistant is touched the
// broker itself is asked whether it takes the delivered login (the provisioner creates it within
// seconds of the grant): if not, nothing is written and the step fails saying why, and Home
// Assistant keeps the login it has — the carried `luffy` on ace, which mosquitto keeps. If Home
// Assistant's own connection test refuses the new settings, the flow saves nothing, and the step
// fails with Home Assistant's reason. A login that may not subscribe to the discovery topics is said
// as a warning: discovery would find nothing.
export const MQTT_PROVISION = "mqtt-topic";
/** Home Assistant's discovery prefix, subscribed to whenever discovery is on (the default). */
export const DISCOVERY_FILTER = "homeassistant/#";
export interface MqttWanted {
host: string;
port: number;
username: string;
password: string;
}
export type Wanted = { ok: true; want: MqttWanted } | { ok: false; problem: string };
/** The broker, port and login the mesh says Home Assistant uses. */
export function wantedMqtt(binding: Binding | undefined, credential: string | undefined): Wanted {
if (!binding) return { ok: false, problem: `no binding for ${MQTT_PROVISION} was delivered — the mesh writes it before this step runs` };
const host = typeof binding.at === "string" ? binding.at.trim() : "";
if (!host) return { ok: false, problem: `the ${MQTT_PROVISION} binding names no host (at)` };
const port = portOf(binding.serves);
if (port === undefined) return { ok: false, problem: `the ${MQTT_PROVISION} binding serves no usable port (${String(binding.serves?.port)})` };
const scheme = binding.serves?.scheme;
if (scheme !== undefined && scheme !== "mqtt") {
return { ok: false, problem: `the ${MQTT_PROVISION} binding serves scheme ${String(scheme)}; this step writes plain MQTT` };
}
const username = typeof binding.as === "string" ? binding.as.trim() : "";
if (!username) return { ok: false, problem: `the ${MQTT_PROVISION} binding names no login (as)` };
const password = (credential ?? "").replace(/\n$/, "");
if (!password) return { ok: false, problem: `the ${MQTT_PROVISION} credential is empty or was not delivered` };
return { ok: true, want: { host, port, username, password } };
}
export interface MqttDeps {
hass: Hass;
probe: Probe;
marks: Marks;
}
const markFor = (entryId: string, w: MqttWanted): string => digest("mqtt", entryId, w.host, w.port, w.username, w.password);
/** Bring Home Assistant's MQTT entry in line with the mesh. Never throws: every failure is an outcome. */
export async function reconcileMqtt(deps: MqttDeps, binding: Binding | undefined, credential: string | undefined): Promise<Outcome> {
const what = "mqtt";
const w = wantedMqtt(binding, credential);
if ("problem" in w) return { what, result: "refused", problem: w.problem };
const want = w.want;
// The broker first: a login it does not take is never written into Home Assistant.
let note: string | undefined;
try {
const probe = await deps.probe(want.host, want.port, want.username, want.password, DISCOVERY_FILTER);
if (probe.connack === 4 || probe.connack === 5) {
return {
what,
result: "refused",
problem:
`the broker at ${want.host}:${want.port} does not (yet) take the login ${want.username} with the delivered ` +
`password (CONNACK ${probe.connack}); mosquitto's provisioner creates it from the grant — nothing was ` +
`written, and Home Assistant keeps the broker login it has`,
};
}
if (probe.connack !== 0) {
return { what, result: "refused", problem: `the broker at ${want.host}:${want.port} answered CONNACK ${probe.connack}; nothing was written` };
}
if (probe.suback === 0x80) {
note =
`warning: ${want.username} may not subscribe to ${DISCOVERY_FILTER} — MQTT discovery will find nothing; ` +
`grant it with the mqtt-topic contribution's \`topics\``;
}
} catch (err) {
return {
what,
result: "refused",
problem: `the broker at ${want.host}:${want.port} could not be asked: ${scrub(err, want.password)}; nothing was written`,
};
}
try {
const entries = (await deps.hass.entries("mqtt")).filter((e) => e.domain === "mqtt");
if (entries.length > 1) {
return { what, result: "refused", problem: `Home Assistant has ${entries.length} MQTT entries; which one the mesh owns is not guessed` };
}
if (entries.length === 0) return await createEntry(deps, want, note);
const entry = entries[0];
const flow = await deps.hass.startFlow("mqtt", entry.entry_id);
if (flow.type !== "form" || !flow.flow_id || !flow.data_schema) {
if (flow.flow_id) await deps.hass.abortFlow(flow.flow_id);
return { what, result: "refused", problem: `Home Assistant's MQTT reconfigure flow answered ${flow.type}${flow.reason ? ` (${flow.reason})` : ""}` };
}
const current = formValues(flow.data_schema);
const fields: string[] = [];
if (String(current.broker ?? "") !== want.host) fields.push("broker");
if (Number(current.port ?? 0) !== want.port) fields.push("port");
if (String(current.username ?? "") !== want.username) fields.push("username");
if ((await deps.marks.get("mqtt")) !== markFor(entry.entry_id, want)) fields.push("password");
if (fields.length === 0) {
await deps.hass.abortFlow(flow.flow_id);
return note ? { what, result: "unchanged", note } : { what, result: "unchanged" };
}
const saved = await deps.hass.stepFlow(flow.flow_id, {
...current,
broker: want.host,
port: want.port,
username: want.username,
password: want.password,
});
if (saved.type === "abort" && saved.reason === "reconfigure_successful") {
await deps.marks.set("mqtt", markFor(entry.entry_id, want));
return { what, result: "written", fields, ...(note ? { note } : {}) };
}
if (saved.flow_id) await deps.hass.abortFlow(saved.flow_id);
return {
what,
result: "refused",
problem:
`Home Assistant's own connection test refused ${want.username}@${want.host}:${want.port} ` +
`(${describe(saved)}); its MQTT entry is unchanged`,
};
} catch (err) {
return { what, result: "refused", problem: scrub(err, want.password) };
}
}
/** A fresh Home Assistant has no MQTT entry: made through the integration's user flow. */
async function createEntry(deps: MqttDeps, want: MqttWanted, note?: string): Promise<Outcome> {
const what = "mqtt";
let flow = await deps.hass.startFlow("mqtt");
if (flow.type === "form" && flow.step_id !== "broker" && flow.flow_id) {
// Anything before the broker form (none outside the Supervisor) is not this step's to answer.
await deps.hass.abortFlow(flow.flow_id);
return { what, result: "refused", problem: `Home Assistant's MQTT user flow asked ${flow.step_id} before the broker` };
}
if (flow.type !== "form" || !flow.flow_id) {
return { what, result: "refused", problem: `Home Assistant's MQTT user flow answered ${describe(flow)}` };
}
const shown = formValues(flow.data_schema);
// A new entry's form has no value for its two certificate choices (a reconfigure pre-fills them
// from the entry): plain MQTT, so neither a CA nor a client certificate.
const other = (shown.other_settings ?? {}) as Record<string, unknown>;
if (flow.data_schema?.some((f) => f.name === "other_settings")) {
shown.other_settings = { set_ca_cert: "off", set_client_cert: false, ...other };
}
flow = await deps.hass.stepFlow(flow.flow_id, {
...shown,
broker: want.host,
port: want.port,
username: want.username,
password: want.password,
});
if (flow.type === "create_entry") {
const id = (flow.result as { entry_id?: string } | undefined)?.entry_id;
if (id) await deps.marks.set("mqtt", markFor(id, want));
return { what, result: "written", fields: ["entry"], ...(note ? { note } : {}) };
}
if (flow.flow_id) await deps.hass.abortFlow(flow.flow_id);
return { what, result: "refused", problem: `Home Assistant refused a new MQTT entry for ${want.host}:${want.port} (${describe(flow)})` };
}
export function describe(r: { type: string; reason?: string; errors?: Record<string, string> | null }): string {
const errors = r.errors ? Object.entries(r.errors).map(([k, v]) => `${k}: ${v}`).join(", ") : "";
return [r.type, r.reason, errors].filter(Boolean).join(" — ");
}
// ---- Sonarr, Radarr, Lidarr ----
// Home Assistant's Sonarr, Radarr and Lidarr integrations, pointed at the apps the mesh bound —
// `sonarr-api`, `radarr-api`, `lidarr-api` (their providers: mesh-catalog #156).
//
// **What Home Assistant lets anyone change, and what it does not.** Each integration keeps a URL and
// an API key in its config entry. None of the three has a reconfigure flow: Home Assistant changes
// them only through the flow its UI runs —
// - a **user flow** makes a new entry (validated against the app);
// - a **reauth flow**, which Home Assistant starts by itself when the app refuses the key it holds,
// takes a new key (Sonarr) or a new URL and key (Radarr, Lidarr);
// - anything else — the URL of a working entry — only by removing the integration and adding it
// again, which throws away its entities' names, areas and history links. **This step never
// removes an entry.**
// So, per app:
// 1. The bound key is tried against the bound app first. Refused, nothing is written: until the
// operator accepts the app's own key for this pair, the mesh delivers a value it minted, which
// no Servarr app takes (novox/hq ADR 0092) — the failure names the `secret accept` that fixes it.
// 2. No entry: one is made through the user flow.
// 3. A reauth flow Home Assistant started for the entry: finished with the bound key (and URL,
// where the integration's reauth asks for one).
// 4. An entry whose URL (read from the device the integration registered, `configuration_url`)
// is the bound one and which is loaded: already as the mesh says. The key needs no digest here:
// a Servarr app has one key, so an entry loaded against the app holds the key the app took.
// 5. A working entry at a different URL that reaches **the same app** — the same process, by the
// app's own status (start time, data folder, version) — is left as it is and said: ace's entries
// say `127.0.0.1:<port>` and the binding says `ace.internal:<port>`, one Sonarr either way.
// 6. Anything else is refused, loudly, with what the operator can do; nothing is removed.
export interface ServarrApp {
/** The integration's domain, also the app. */
domain: "sonarr" | "radarr" | "lidarr";
/** The provision it is required as: the `requires`, `binds` and `secrets` key. */
provision: string;
/** The app's status endpoint: answers 401 to a wrong key, and says which process answered. */
statusPath: string;
}
export const APPS: readonly ServarrApp[] = [
{ domain: "sonarr", provision: "sonarr-api", statusPath: "/api/v3/system/status" },
{ domain: "radarr", provision: "radarr-api", statusPath: "/api/v3/system/status" },
{ domain: "lidarr", provision: "lidarr-api", statusPath: "/api/v1/system/status" },
];
/** The HTTP the step needs toward the apps, so a test can stand fakes in. */
export interface Http {
fetch(url: string, init?: { method?: string; headers?: Record<string, string> }): Promise<{ status: number; text(): Promise<string> }>;
}
export type AppWanted = { ok: true; url: string; key: string; from: string } | { ok: false; problem: string };
/** The URL and key the mesh says Home Assistant uses for this app. */
export function wantedApp(spec: ServarrApp, binding: Binding | undefined, credential: string | undefined): AppWanted {
if (!binding) return { ok: false, problem: `no binding for ${spec.provision} was delivered — the mesh writes it before this step runs` };
const at = typeof binding.at === "string" ? binding.at.trim() : "";
if (!at) return { ok: false, problem: `the ${spec.provision} binding names no host (at)` };
const port = portOf(binding.serves);
if (port === undefined) return { ok: false, problem: `the ${spec.provision} binding serves no usable port (${String(binding.serves?.port)})` };
const scheme = typeof binding.serves?.scheme === "string" && binding.serves.scheme ? binding.serves.scheme : "http";
if (scheme !== "http" && scheme !== "https") return { ok: false, problem: `the ${spec.provision} binding serves scheme ${scheme}` };
const base = typeof binding.serves?.["url-base"] === "string" ? String(binding.serves["url-base"]).trim().replace(/^\/+|\/+$/g, "") : "";
const key = (credential ?? "").trim();
if (!key) return { ok: false, problem: `the ${spec.provision} credential is empty or was not delivered` };
return {
ok: true,
url: `${scheme}://${urlHost(at)}:${port}${base ? `/${base}` : ""}`,
key,
from: typeof binding.from === "string" ? binding.from : "",
};
}
/** Two URLs naming the same place: scheme, host, port (explicit or default) and base path. */
export function sameUrl(a: string | null | undefined, b: string): boolean {
if (!a) return false;
try {
const x = new URL(a);
const y = new URL(b);
const port = (u: URL) => u.port || (u.protocol === "https:" ? "443" : "80");
const path = (u: URL) => u.pathname.replace(/\/+$/, "");
return x.protocol === y.protocol && x.hostname.toLowerCase() === y.hostname.toLowerCase() && port(x) === port(y) && path(x) === path(y);
} catch {
return false;
}
}
type Status = { taken: true; status: Record<string, unknown> } | { taken: false };
/** The app's status with this key: `taken: false` when it refuses the key; throws when it cannot be asked. */
export async function appStatus(http: Http, spec: ServarrApp, url: string, key: string): Promise<Status> {
const res = await http.fetch(`${url.replace(/\/+$/, "")}${spec.statusPath}`, {
method: "GET",
headers: { "X-Api-Key": key, Accept: "application/json" },
});
if (res.status === 401 || res.status === 403) return { taken: false };
if (res.status < 200 || res.status >= 300) throw new Error(`${spec.domain} answered ${res.status} at ${spec.statusPath}`);
return { taken: true, status: JSON.parse(await res.text()) as Record<string, unknown> };
}
/** Whether two status answers came from one running app. */
export function sameInstance(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
const facts = ["startTime", "appData", "version"];
return facts.every((k) => a[k] !== undefined && a[k] !== null && a[k] === b[k]);
}
/** The remedy for a refused key, in the controller's words (ADR 0092). */
export function acceptRemedy(spec: ServarrApp, from: string): string {
return (
`${spec.domain} refuses the ${spec.provision} credential the mesh delivered, so nothing was written into ` +
`Home Assistant. A Servarr app has one API key and the mesh cannot make it: accept ${spec.domain}'s own key ` +
`for this pair — \`secret accept <this node> home-assistant ${spec.provision} --provider ${from || "<its node>"} ` +
`--from <file holding ${spec.domain}'s ApiKey>\``
);
}
export interface ServarrDeps {
hass: Hass;
http: Http;
}
/** The input a Servarr form takes: what it shows, with the URL (where asked) and the key laid over. */
function servarrInput(schema: readonly SchemaField[] | null | undefined, url: string, key: string): Record<string, unknown> {
const input = formValues(schema);
if (hasField(schema, "url")) input.url = url;
if (hasField(schema, "api_key")) input.api_key = key;
return input;
}
/** Bring Home Assistant's entry for one app in line with the mesh. Never throws. */
export async function reconcileApp(deps: ServarrDeps, spec: ServarrApp, binding: Binding | undefined, credential: string | undefined): Promise<Outcome> {
const what = spec.domain;
const w = wantedApp(spec, binding, credential);
if ("problem" in w) return { what, result: "refused", problem: w.problem };
let bound: Status;
try {
bound = await appStatus(deps.http, spec, w.url, w.key);
} catch (err) {
return { what, result: "refused", problem: `${spec.domain} could not be asked at ${w.url}: ${scrub(err, w.key)}` };
}
if (!bound.taken) return { what, result: "refused", problem: acceptRemedy(spec, w.from) };
try {
const entries = (await deps.hass.entries(spec.domain)).filter((e) => e.domain === spec.domain);
if (entries.length > 1) {
return { what, result: "refused", problem: `Home Assistant has ${entries.length} ${spec.domain} entries; which one the mesh owns is not guessed` };
}
// No entry: made, through the integration's own user flow, which validates the key itself.
if (entries.length === 0) {
const flow = await deps.hass.startFlow(spec.domain);
if (flow.type !== "form" || !flow.flow_id) return { what, result: "refused", problem: `Home Assistant's ${spec.domain} user flow answered ${describe(flow)}` };
const made = await deps.hass.stepFlow(flow.flow_id, servarrInput(flow.data_schema, w.url, w.key));
if (made.type === "create_entry") return { what, result: "written", fields: ["entry"] };
if (made.flow_id) await deps.hass.abortFlow(made.flow_id);
return { what, result: "refused", problem: `Home Assistant refused a new ${spec.domain} entry at ${w.url} (${describe(made)})` };
}
const entry = entries[0];
if (entry.disabled_by) return { what, result: "unchanged", note: `the ${spec.domain} entry is disabled (by ${entry.disabled_by}); left alone` };
// A reauth Home Assistant started because the app refused its key: finished with the bound one.
const reauth = (await deps.hass.flowsInProgress()).find(
(f) => f.handler === spec.domain && f.context?.source === "reauth" && f.context?.entry_id === entry.entry_id,
);
if (reauth) {
let step = await deps.hass.stepFlow(reauth.flow_id, {}); // reauth_confirm: a confirmation, no fields
if (step.type === "form" && step.flow_id && step.step_id !== "reauth_confirm") {
const input = servarrInput(step.data_schema, w.url, w.key);
const fields = ["api_key", ...(hasField(step.data_schema, "url") ? ["url"] : [])];
step = await deps.hass.stepFlow(step.flow_id, input);
if (step.type === "abort" && step.reason === "reauth_successful") return { what, result: "written", fields };
}
return { what, result: "refused", problem: `Home Assistant's ${spec.domain} reauth did not take the bound key and URL (${describe(step)})` };
}
const device = (await deps.hass.devices()).find((d) => d.config_entries?.includes(entry.entry_id) && d.configuration_url);
const current = device?.configuration_url ?? undefined;
if (entry.state === "loaded" && sameUrl(current, w.url)) return { what, result: "unchanged" };
if (entry.state === "loaded" && current) {
let there: Status | undefined;
try {
there = await appStatus(deps.http, spec, current, w.key);
} catch {
there = undefined;
}
if (there?.taken && sameInstance(there.status, bound.status)) {
return {
what,
result: "equivalent",
note:
`Home Assistant reaches ${spec.domain} at ${current}, the same running app the mesh bound at ${w.url}; ` +
`Home Assistant has no way to change a working ${spec.domain} entry's URL short of removing it, so it is left as it is`,
};
}
}
return {
what,
result: "refused",
problem:
`Home Assistant's ${spec.domain} entry (${entry.state ?? "unknown state"}) points at ${current ?? "an unknown URL"}, ` +
`not the ${spec.domain} the mesh bound at ${w.url}. Home Assistant only lets a working entry's URL change by ` +
`removing and re-adding the integration, which this step never does: remove it in Home Assistant ` +
`(Settings → Devices & services → ${spec.domain}) and the next run adds it at the bound URL`,
};
} catch (err) {
return { what, result: "refused", problem: scrub(err, w.key) };
}
}
+160
View File
@@ -0,0 +1,160 @@
// Home Assistant's own configuration API, as the provisions step uses it — the supported way to
// change an integration's connection. Home Assistant keeps every integration in
// `.storage/core.config_entries`, a file it owns and rewrites; the mesh may not write it, and it is
// not a file the mesh could merge into. What Home Assistant offers instead is the same thing its UI
// uses: **config flows** over REST (`/api/config/config_entries/flow`) — a user flow creates an
// entry, a reconfigure flow changes one, a reauth flow (which Home Assistant starts itself when a
// credential stops working) replaces its credential — each validated by the integration's own
// connection test before anything is saved. The two things REST does not answer (which flows Home
// Assistant has started, which device an entry made) come over its WebSocket API.
//
// Nothing here reads `.storage`. Authenticated with the module's accepted long-lived access token.
/** A config entry as `GET /api/config/config_entries/entry` lists it — no data, no credentials. */
export interface ConfigEntry {
entry_id: string;
domain: string;
title?: string;
source?: string;
state?: string;
disabled_by?: string | null;
}
/** One field of a flow's form, as Home Assistant serializes a voluptuous schema. */
export interface SchemaField {
name: string;
type?: string;
required?: boolean;
optional?: boolean;
default?: unknown;
description?: { suggested_value?: unknown } | null;
/** A section (`type: "expandable"`) carries its own fields. */
schema?: SchemaField[];
}
/** What a flow answered: another form, an entry made, or the flow ended (abort). */
export interface FlowResult {
type: string;
flow_id?: string;
handler?: string;
step_id?: string;
data_schema?: SchemaField[] | null;
errors?: Record<string, string> | null;
reason?: string;
result?: { entry_id?: string } | unknown;
}
/** A flow in progress that Home Assistant started itself (a reauth, a discovery). */
export interface FlowProgress {
flow_id: string;
handler: string;
step_id?: string;
context?: { source?: string; entry_id?: string };
}
/** A device from the device registry; an integration names where its app is as configuration_url. */
export interface DeviceEntry {
id: string;
config_entries?: string[];
configuration_url?: string | null;
}
export interface Hass {
entries(domain: string): Promise<ConfigEntry[]>;
/** A user flow for `handler`, or — given an entry — a reconfigure flow for it. */
startFlow(handler: string, entryId?: string): Promise<FlowResult>;
stepFlow(flowId: string, input: Record<string, unknown>): Promise<FlowResult>;
abortFlow(flowId: string): Promise<void>;
flowsInProgress(): Promise<FlowProgress[]>;
devices(): Promise<DeviceEntry[]>;
}
/** Home Assistant over HTTP: REST for entries and flows, one short WebSocket session per question. */
export class HassApi implements Hass {
private readonly base: string;
constructor(url: string, private readonly token: string) {
this.base = url.replace(/\/$/, "");
}
private async rest(method: string, path: string, body?: unknown): Promise<unknown> {
const res = await fetch(`${this.base}${path}`, {
method,
headers: {
Authorization: `Bearer ${this.token}`,
Accept: "application/json",
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
if (!res.ok) {
// Home Assistant's error text names fields, never echoes their values.
throw new Error(`Home Assistant ${method} ${path} answered ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`);
}
return text ? (JSON.parse(text) as unknown) : undefined;
}
async entries(domain: string): Promise<ConfigEntry[]> {
return ((await this.rest("GET", `/api/config/config_entries/entry?domain=${encodeURIComponent(domain)}`)) ??
[]) as ConfigEntry[];
}
async startFlow(handler: string, entryId?: string): Promise<FlowResult> {
return (await this.rest("POST", "/api/config/config_entries/flow", {
handler,
show_advanced_options: true,
...(entryId ? { entry_id: entryId } : {}),
})) as FlowResult;
}
async stepFlow(flowId: string, input: Record<string, unknown>): Promise<FlowResult> {
return (await this.rest("POST", `/api/config/config_entries/flow/${encodeURIComponent(flowId)}`, input)) as FlowResult;
}
async abortFlow(flowId: string): Promise<void> {
await this.rest("DELETE", `/api/config/config_entries/flow/${encodeURIComponent(flowId)}`).catch(() => undefined);
}
async flowsInProgress(): Promise<FlowProgress[]> {
return (await this.ws("config_entries/flow/progress")) as FlowProgress[];
}
async devices(): Promise<DeviceEntry[]> {
return (await this.ws("config/device_registry/list")) as DeviceEntry[];
}
/** One WebSocket command: connect, authenticate, ask, close. */
private ws(type: string): Promise<unknown> {
const url = `${this.base.replace(/^http/, "ws")}/api/websocket`;
return new Promise((resolve, reject) => {
const socket = new WebSocket(url);
const timer = setTimeout(() => {
socket.close();
reject(new Error(`Home Assistant's WebSocket did not answer ${type} within 30s`));
}, 30_000);
const done = (fn: () => void): void => {
clearTimeout(timer);
socket.close();
fn();
};
socket.onerror = () => done(() => reject(new Error(`Home Assistant's WebSocket at ${url} failed`)));
socket.onmessage = (event: { data: unknown }) => {
const msg = JSON.parse(String(event.data)) as {
type: string;
id?: number;
success?: boolean;
result?: unknown;
error?: { message?: string };
};
if (msg.type === "auth_required") socket.send(JSON.stringify({ type: "auth", access_token: this.token }));
else if (msg.type === "auth_invalid") done(() => reject(new Error("Home Assistant refused the token")));
else if (msg.type === "auth_ok") socket.send(JSON.stringify({ id: 1, type }));
else if (msg.type === "result" && msg.id === 1) {
if (msg.success) done(() => resolve(msg.result));
else done(() => reject(new Error(`Home Assistant ${type}: ${msg.error?.message ?? "failed"}`)));
}
};
});
}
}
@@ -0,0 +1,84 @@
// home-assistant's provisions step — run once by the host after Home Assistant starts, and again
// whenever a binding or pair credential it reads changes (the container's `restart-on`, novox/hq
// ADR 0099). It points Home Assistant's MQTT integration at the `mqtt-topic` broker and its Sonarr,
// Radarr and Lidarr integrations at the `sonarr-api`, `radarr-api` and `lidarr-api` apps, through
// Home Assistant's own config flows (connections.ts). It connects to no mesh broker.
//
// Exits non-zero when anything could not be put right, so the node reports the step failed and the
// host runs it again on the next apply. Declared last in the manifest, so its failing gates nothing
// else of home-assistant's (novox/hq ADR 0136). Never prints a key or password.
import { join } from "node:path";
import { APPS, MQTT_PROVISION, reconcileApp, reconcileMqtt, type Outcome } from "./connections.js";
import { HassApi } from "./hass.js";
import { marksIn, readBinding, readIfThere } from "./mesh.js";
import { probeBroker } from "./probe.js";
const dir = process.env.MESH_PROVISIONS_DIR ?? "/run/provisions";
const url = process.env.MESH_HOMEASSISTANT_URL ?? "http://127.0.0.1:8123";
const token = (await readIfThere(process.env.MESH_HOMEASSISTANT_TOKEN_FILE))?.trim() ?? "";
const marks = marksIn(process.env.MESH_WRITTEN_DIR ?? "/var/lib/home-assistant-provisions");
const waitSeconds = Number(process.env.MESH_HOMEASSISTANT_WAIT_SECONDS ?? "300");
if (!token) {
console.error("[hass-provisions] no Home Assistant token — home-assistant's own `token` secret has not been accepted");
process.exit(1);
}
/** Home Assistant answers /api/ with 200 once it is up and the token is good. */
async function ready(): Promise<boolean> {
const until = Date.now() + waitSeconds * 1000;
for (;;) {
try {
const res = await fetch(`${url.replace(/\/$/, "")}/api/`, { headers: { Authorization: `Bearer ${token}` } });
if (res.status === 200) return true;
if (res.status === 401 || res.status === 403) {
console.error("[hass-provisions] Home Assistant refuses the token — accept a long-lived access token it issued");
return false;
}
} catch {
// not listening yet
}
if (Date.now() >= until) return false;
await new Promise((r) => setTimeout(r, 3000));
}
}
if (!(await ready())) {
console.error(`[hass-provisions] Home Assistant did not answer at ${url} within ${waitSeconds}s`);
process.exit(1);
}
const hass = new HassApi(url, token);
const read = async (p: string) => [await readBinding(join(dir, `${p}.json`)), await readIfThere(join(dir, `${p}.secret`))] as const;
const outcomes: Outcome[] = [];
{
const [binding, secret] = await read(MQTT_PROVISION);
outcomes.push(await reconcileMqtt({ hass, probe: probeBroker, marks }, binding, secret));
}
for (const spec of APPS) {
const [binding, secret] = await read(spec.provision);
outcomes.push(await reconcileApp({ hass, http: { fetch: (u, init) => fetch(u, init) } }, spec, binding, secret));
}
let failed = 0;
for (const o of outcomes) {
switch (o.result) {
case "unchanged":
console.log(`[hass-provisions] ${o.what}: already as the mesh says${o.note ? ` — ${o.note}` : ""}`);
break;
case "written":
console.log(`[hass-provisions] ${o.what}: wrote ${o.fields.join(", ")}; Home Assistant's own test passed${o.note ? ` — ${o.note}` : ""}`);
break;
case "equivalent":
console.log(`[hass-provisions] ${o.what}: ${o.note}`);
break;
case "refused":
failed++;
console.error(`[hass-provisions] ${o.what}: ${o.problem}`);
break;
}
}
process.exitCode = failed > 0 ? 1 : 0;
+42
View File
@@ -0,0 +1,42 @@
// What the mesh delivered to home-assistant's provisions step, and the step's own small memory.
//
// Per provision it requires, the mesh writes two files beside each other (the manifest's `binds` and
// `secrets`): `<provision>.json`, the binding — where the provider is (`at`), what it serves (`port`,
// `scheme`, …) and the login this module presents (`as`) — and `<provision>.secret`, the pair
// credential. Nothing here guesses a host, a port or a key.
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { Binding, Marks } from "./connections.js";
/** A file the mesh wrote, or undefined when it is not there. */
export async function readIfThere(path: string | undefined): Promise<string | undefined> {
if (!path) return undefined;
return readFile(path, "utf8").catch(() => undefined);
}
/** A binding file parsed, or undefined when absent or not JSON. */
export async function readBinding(path: string): Promise<Binding | undefined> {
const raw = await readIfThere(path);
if (raw === undefined) return undefined;
try {
return JSON.parse(raw) as Binding;
} catch {
return undefined;
}
}
export function marksIn(dir: string): Marks {
return {
async get(name) {
return (await readIfThere(join(dir, `${name}.digest`)))?.trim() || undefined;
},
async set(name, value) {
await mkdir(dir, { recursive: true, mode: 0o700 });
const path = join(dir, `${name}.digest`);
await writeFile(`${path}.tmp`, `${value}\n`, { mode: 0o600 });
await rename(`${path}.tmp`, path);
},
};
}
+117
View File
@@ -0,0 +1,117 @@
// Ask the broker, before Home Assistant is told anything, whether it takes the login and password
// the mesh delivered — and whether that login may subscribe to Home Assistant's discovery topics.
//
// One MQTT 3.1.1 session: CONNECT (clean, a throwaway client id, so Home Assistant's own session is
// never taken over), read the CONNACK, optionally SUBSCRIBE once and read the SUBACK, DISCONNECT.
// No dependency: the handful of bytes MQTT needs for this are written here.
import { randomBytes } from "node:crypto";
import { connect } from "node:net";
export interface ProbeResult {
/** 0 accepted; 4 bad username or password; 5 not authorised. */
connack: number;
/** The SUBACK return code for the filter asked about: 0–2 granted, 0x80 refused. */
suback?: number;
}
export type Probe = (host: string, port: number, username: string, password: string, subscribe?: string) => Promise<ProbeResult>;
function str(v: string): Buffer {
const b = Buffer.from(v, "utf8");
const len = Buffer.alloc(2);
len.writeUInt16BE(b.length);
return Buffer.concat([len, b]);
}
function packet(type: number, body: Buffer): Buffer {
let remaining = body.length;
const lenBytes: number[] = [];
do {
let byte = remaining % 128;
remaining = Math.floor(remaining / 128);
if (remaining > 0) byte |= 0x80;
lenBytes.push(byte);
} while (remaining > 0);
return Buffer.concat([Buffer.from([type, ...lenBytes]), body]);
}
/** The first complete packet in `buf`: its type byte, its body, and how many bytes it took. */
export function firstPacket(buf: Buffer): { type: number; body: Buffer; used: number } | undefined {
if (buf.length < 2) return undefined;
let length = 0;
let multiplier = 1;
let i = 1;
for (;;) {
if (i >= buf.length) return undefined;
const byte = buf[i++];
length += (byte & 0x7f) * multiplier;
if ((byte & 0x80) === 0) break;
multiplier *= 128;
if (i > 4) throw new Error("malformed MQTT remaining length");
}
if (buf.length < i + length) return undefined;
return { type: buf[0], body: buf.subarray(i, i + length), used: i + length };
}
export const probeBroker: Probe = (host, port, username, password, subscribe) => {
const connectBody = Buffer.concat([
str("MQTT"),
Buffer.from([4, 0xc2, 0, 10]), // level 4 (3.1.1); username + password + clean session; keepalive 10s
str(`mesh-probe-${randomBytes(6).toString("hex")}`),
str(username),
str(password),
]);
return new Promise((resolve, reject) => {
const socket = connect({ host, port });
let buf = Buffer.alloc(0);
const result: ProbeResult = { connack: -1 };
const timer = setTimeout(() => {
socket.destroy();
reject(new Error(`no answer from the broker at ${host}:${port} within 10s`));
}, 10_000);
const finish = (): void => {
clearTimeout(timer);
if (result.connack === 0) socket.end(Buffer.from([0xe0, 0]));
else socket.destroy();
resolve(result);
};
socket.on("connect", () => socket.write(packet(0x10, connectBody)));
socket.on("data", (chunk) => {
buf = Buffer.concat([buf, chunk]);
for (;;) {
let p;
try {
p = firstPacket(buf);
} catch (err) {
clearTimeout(timer);
socket.destroy();
reject(err);
return;
}
if (!p) return;
buf = buf.subarray(p.used);
const kind = p.type >> 4;
if (kind === 2) {
result.connack = p.body[1] ?? -1;
if (result.connack !== 0 || !subscribe) return finish();
// SUBSCRIBE, packet id 1, one filter at QoS 0.
socket.write(packet(0x82, Buffer.concat([Buffer.from([0, 1]), str(subscribe), Buffer.from([0])])));
} else if (kind === 9) {
result.suback = p.body[2];
return finish();
}
}
});
socket.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
socket.on("close", () => {
if (result.connack === -1) {
clearTimeout(timer);
reject(new Error(`the broker at ${host}:${port} closed the connection without answering`));
}
});
});
};
@@ -0,0 +1,293 @@
// What holds home-assistant's provisions step (provisions/*.ts): Home Assistant's MQTT entry is
// made to use the broker, port and login the mesh bound — only after the broker takes that login,
// through the reconfigure flow, keeping every other setting as Home Assistant pre-filled it, and not
// again once it already says so; its Sonarr/Radarr/Lidarr entries are made, finished (reauth), left
// alone when they already reach the bound app, and never removed; a key the app refuses (the mesh's
// minted value before the operator accepts the app's) is never written.
//
// Home Assistant and the apps are fakes answering as the real ones do (flow shapes checked against
// ghcr.io/home-assistant/home-assistant 2026.9.3, the build ace runs).
import { test } from "node:test";
import assert from "node:assert/strict";
import type { ConfigEntry, DeviceEntry, FlowProgress, FlowResult, Hass, SchemaField } from "../provisions/hass.ts";
import type { Binding, Marks } from "../provisions/connections.ts";
import { APPS, formValues, reconcileApp, reconcileMqtt, sameUrl, type Http, type ServarrApp } from "../provisions/connections.ts";
import type { Probe } from "../provisions/probe.ts";
const PWD_NOT_CHANGED = "__**password_not_changed**__";
const MINTED = "mesh-minted-password";
function mqttBinding(): Binding {
return { provision: "mqtt-topic", from: "ace", at: "ace.internal", as: "mesh_ace_hass", serves: { scheme: "mqtt", port: 1883 } };
}
/** The MQTT reconfigure form as Home Assistant serializes it, pre-filled from an entry. */
function brokerForm(data: Record<string, unknown>): SchemaField[] {
return [
{ name: "broker", type: "string", required: true, description: { suggested_value: data.broker } },
{ name: "port", type: "integer", required: true, default: 1883, description: { suggested_value: data.port } },
{ name: "protocol", type: "select", required: true, default: "3.1.1", description: { suggested_value: data.protocol } },
{ name: "username", type: "string", optional: true, description: { suggested_value: data.username } },
{ name: "password", type: "string", optional: true, description: { suggested_value: data.password ? PWD_NOT_CHANGED : undefined } },
{
name: "other_settings",
type: "expandable",
required: true,
schema: [
{ name: "keepalive", type: "integer", optional: true, description: { suggested_value: 60 } },
{ name: "transport", type: "select", required: true, default: "tcp", description: { suggested_value: "tcp" } },
{ name: "set_ca_cert", type: "select", required: true, description: { suggested_value: "off" } },
{ name: "set_client_cert", type: "boolean", required: true, description: { suggested_value: false } },
],
},
];
}
interface FakeOpts {
entries?: Record<string, (ConfigEntry & { data: Record<string, unknown> })[]>;
/** What Home Assistant's own connection test accepts. */
accepts?: (data: Record<string, unknown>) => boolean;
reauth?: FlowProgress[];
devices?: DeviceEntry[];
}
function fakeHass(opts: FakeOpts = {}) {
const entries = opts.entries ?? {};
const calls: string[] = [];
const submitted: Record<string, unknown>[] = [];
const flows = new Map<string, { handler: string; entryId?: string; step: string; reauth?: boolean }>();
let n = 0;
const accepts = opts.accepts ?? (() => true);
const form = (id: string, step: string, schema: SchemaField[], errors?: Record<string, string>): FlowResult => ({
type: "form", flow_id: id, step_id: step, data_schema: schema, errors: errors ?? null,
});
const servarrUser: SchemaField[] = [
{ name: "url", type: "string", required: true },
{ name: "api_key", type: "string", required: true },
{ name: "more_options", type: "expandable", required: true, schema: [{ name: "verify_ssl", type: "boolean", optional: true, default: false }] },
];
const hass: Hass = {
async entries(domain) {
calls.push(`entries ${domain}`);
return (entries[domain] ?? []).map(({ data: _d, ...e }) => e);
},
async startFlow(handler, entryId) {
calls.push(`start ${handler}${entryId ? ` ${entryId}` : ""}`);
const id = `f${++n}`;
if (handler === "mqtt") {
const entry = entryId ? entries.mqtt.find((e) => e.entry_id === entryId) : undefined;
if (entryId && !entry) return { type: "abort", reason: "not_found" };
flows.set(id, { handler, entryId, step: "broker" });
return form(id, "broker", brokerForm(entry?.data ?? {}));
}
if (entryId) return { type: "abort", reason: "not_implemented" }; // no reconfigure for Servarr
flows.set(id, { handler, step: "user" });
return form(id, "user", servarrUser);
},
async stepFlow(flowId, input) {
calls.push(`step ${flowId}`);
const flow = flows.get(flowId);
if (!flow) throw new Error(`Home Assistant POST flow/${flowId} answered 404`);
if (flow.step === "reauth_confirm") {
flow.step = "user";
return form(flowId, "user", [
{ name: "url", type: "string", required: true, default: "http://old:1" },
{ name: "api_key", type: "string", optional: true },
{ name: "verify_ssl", type: "boolean", optional: true, default: false },
]);
}
submitted.push(input);
if (flow.handler === "mqtt") {
const entry = entries.mqtt?.find((e) => e.entry_id === flow.entryId);
const data = { ...input, ...(input.password === PWD_NOT_CHANGED ? { password: entry?.data.password } : {}) };
if (!accepts(data)) return form(flowId, "broker", brokerForm(data), { base: "cannot_connect" });
flows.delete(flowId);
if (entry) {
entry.data = data;
return { type: "abort", reason: "reconfigure_successful" };
}
(entries.mqtt ??= []).push({ entry_id: "new-mqtt", domain: "mqtt", state: "loaded", data });
return { type: "create_entry", result: { entry_id: "new-mqtt" } };
}
if (!accepts(input)) return form(flowId, "user", servarrUser, { base: "invalid_auth" });
flows.delete(flowId);
if (flow.reauth) return { type: "abort", reason: "reauth_successful" };
(entries[flow.handler] ??= []).push({ entry_id: `new-${flow.handler}`, domain: flow.handler, state: "loaded", data: input });
return { type: "create_entry", result: { entry_id: `new-${flow.handler}` } };
},
async abortFlow(flowId) {
calls.push(`abort ${flowId}`);
flows.delete(flowId);
},
async flowsInProgress() {
for (const f of opts.reauth ?? []) flows.set(f.flow_id, { handler: f.handler, entryId: f.context?.entry_id, step: "reauth_confirm", reauth: true });
return opts.reauth ?? [];
},
async devices() {
return opts.devices ?? [];
},
};
return { hass, calls, submitted, entries };
}
function memoryMarks(): Marks & { store: Map<string, string> } {
const store = new Map<string, string>();
return { store, get: async (k) => store.get(k), set: async (k, v) => void store.set(k, v) };
}
const takes = (suback = 0): Probe => async (_h, _p, user, pass) => ({ connack: user === "mesh_ace_hass" && pass === MINTED ? 0 : 5, suback });
const aceMqttEntry = () => ({
entry_id: "7d1e", domain: "mqtt", state: "loaded",
data: { broker: "127.0.0.1", port: 1883, protocol: "5", username: "luffy", password: "luffys-password" },
});
test("mqtt: the broker is asked first; a login it does not take is never written", async () => {
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] } });
const out = await reconcileMqtt({ hass: f.hass, probe: async () => ({ connack: 5 }), marks: memoryMarks() }, mqttBinding(), MINTED);
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /does not \(yet\) take the login mesh_ace_hass/);
assert.deepEqual(f.calls, []); // Home Assistant not even asked
assert.equal(f.entries.mqtt[0].data.username, "luffy");
});
test("mqtt: ace's entry (127.0.0.1, luffy) is moved to the bound broker and login, every other setting kept", async () => {
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] } });
const marks = memoryMarks();
const out = await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), `${MINTED}\n`);
assert.deepEqual(out, { what: "mqtt", result: "written", fields: ["broker", "username", "password"] });
assert.deepEqual(f.entries.mqtt[0].data, {
broker: "ace.internal", port: 1883, protocol: "5", username: "mesh_ace_hass", password: MINTED,
other_settings: { keepalive: 60, transport: "tcp", set_ca_cert: "off", set_client_cert: false },
});
assert.ok(marks.store.get("mqtt"));
assert.ok(![...marks.store.values()].some((v) => v.includes(MINTED)));
// Run again: nothing differs, the flow is opened to read and closed without submitting.
const before = f.submitted.length;
const again = await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), MINTED);
assert.deepEqual(again, { what: "mqtt", result: "unchanged" });
assert.equal(f.submitted.length, before);
assert.match(f.calls.at(-1) ?? "", /^abort /);
});
test("mqtt: a new password alone is written (the digest tells)", async () => {
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] } });
const marks = memoryMarks();
await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), MINTED);
const rotated: Probe = async () => ({ connack: 0, suback: 0 });
const out = await reconcileMqtt({ hass: f.hass, probe: rotated, marks }, mqttBinding(), "rotated");
assert.deepEqual(out, { what: "mqtt", result: "written", fields: ["password"] });
assert.equal(f.entries.mqtt[0].data.password, "rotated");
});
test("mqtt: Home Assistant's own connection test refusing saves nothing and fails loudly", async () => {
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] }, accepts: () => false });
const marks = memoryMarks();
const out = await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), MINTED);
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /cannot_connect.*unchanged/);
assert.equal(f.entries.mqtt[0].data.username, "luffy");
assert.equal(marks.store.size, 0);
});
test("mqtt: a fresh Home Assistant gets an entry; a grant without the discovery topics is warned about", async () => {
const f = fakeHass();
const out = await reconcileMqtt({ hass: f.hass, probe: takes(0x80), marks: memoryMarks() }, mqttBinding(), MINTED);
assert.equal(out.result, "written");
assert.match((out as { note?: string }).note ?? "", /may not subscribe to homeassistant\/#/);
assert.equal(f.entries.mqtt[0].data.broker, "ace.internal");
});
test("mqtt: two entries, or a binding without a port, are refused rather than guessed", async () => {
const f = fakeHass({ entries: { mqtt: [aceMqttEntry(), { ...aceMqttEntry(), entry_id: "other" }] } });
assert.equal((await reconcileMqtt({ hass: f.hass, probe: takes(), marks: memoryMarks() }, mqttBinding(), MINTED)).result, "refused");
const noPort = { ...mqttBinding(), serves: {} };
assert.match(((await reconcileMqtt({ hass: f.hass, probe: takes(), marks: memoryMarks() }, noPort, MINTED)) as { problem: string }).problem, /no usable port/);
});
// ---- Servarr ----
const SONARR = APPS.find((a) => a.domain === "sonarr") as ServarrApp;
const RADARR = APPS.find((a) => a.domain === "radarr") as ServarrApp;
const KEY = "the-apps-own-key";
const servarrBinding = (port: number, at = "ace.internal"): Binding => ({ provision: "sonarr-api", from: "ace", at, as: "mesh_ace_hass", serves: { scheme: "http", port, "url-base": "" } });
/** One running Sonarr, answering on several addresses (127.0.0.1 and ace.internal are one host). */
function apps(instances: Record<string, { startTime: string }>): Http & { asked: string[] } {
const asked: string[] = [];
return {
asked,
async fetch(url, init) {
asked.push(url);
const u = new URL(url);
const inst = instances[`${u.hostname}:${u.port}`];
if (!inst) throw new Error("connect ECONNREFUSED");
if (init?.headers?.["X-Api-Key"] !== KEY) return { status: 401, text: async () => "" };
return { status: 200, text: async () => JSON.stringify({ version: "4.0.15", appData: "/config", startTime: inst.startTime }) };
},
};
}
const oneSonarr = () => apps({ "ace.internal:8989": { startTime: "t1" }, "127.0.0.1:8989": { startTime: "t1" } });
const sonarrEntry = (state = "loaded") => ({ entry_id: "5a1d", domain: "sonarr", state, data: { url: "http://127.0.0.1:8989", api_key: KEY } });
test("servarr: the mesh's minted key is never written; the remedy names the accept", async () => {
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] } });
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), "minted-by-the-mesh");
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /secret accept <this node> home-assistant sonarr-api --provider ace/);
assert.deepEqual(f.calls, []);
});
test("servarr: ace's entry at 127.0.0.1 reaches the same Sonarr the mesh bound at ace.internal — left, and said", async () => {
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://127.0.0.1:8989" }] });
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), KEY);
assert.equal(out.result, "equivalent");
assert.equal(f.submitted.length, 0);
});
test("servarr: an entry already at the bound URL is unchanged", async () => {
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://ace.internal:8989" }] });
assert.deepEqual(await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), KEY), { what: "sonarr", result: "unchanged" });
});
test("servarr: a working entry that reaches a different app is refused, and nothing is removed", async () => {
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://127.0.0.1:8989" }] });
const two = apps({ "ace.internal:8989": { startTime: "t1" }, "127.0.0.1:8989": { startTime: "another" } });
const out = await reconcileApp({ hass: f.hass, http: two }, SONARR, servarrBinding(8989), KEY);
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /never does/);
assert.equal(f.entries.sonarr.length, 1);
});
test("servarr: no entry — one is made at the bound URL through the user flow", async () => {
const f = fakeHass({ entries: {} });
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), KEY);
assert.deepEqual(out, { what: "sonarr", result: "written", fields: ["entry"] });
assert.deepEqual(f.submitted[0], { url: "http://ace.internal:8989", api_key: KEY, more_options: { verify_ssl: false } });
});
test("servarr: a reauth Home Assistant started is finished with the bound URL and key", async () => {
const entry = { ...sonarrEntry("setup_error"), domain: "radarr", entry_id: "1955" };
const f = fakeHass({
entries: { radarr: [entry] },
reauth: [{ flow_id: "r1", handler: "radarr", step_id: "reauth_confirm", context: { source: "reauth", entry_id: "1955" } }],
});
const radarr = apps({ "ace.internal:7878": { startTime: "t" } });
const out = await reconcileApp({ hass: f.hass, http: radarr }, RADARR, { ...servarrBinding(7878), provision: "radarr-api" }, KEY);
assert.deepEqual(out, { what: "radarr", result: "written", fields: ["api_key", "url"] });
assert.deepEqual(f.submitted[0], { url: "http://ace.internal:7878", api_key: KEY, verify_ssl: false });
});
test("form values: suggested first, then default, sections nested", () => {
assert.deepEqual(formValues(brokerForm({ broker: "b", port: 1, protocol: "5", username: "u", password: "p" })), {
broker: "b", port: 1, protocol: "5", username: "u", password: PWD_NOT_CHANGED,
other_settings: { keepalive: 60, transport: "tcp", set_ca_cert: "off", set_client_cert: false },
});
assert.ok(sameUrl("http://ace.internal:8989/", "http://ace.internal:8989"));
assert.ok(sameUrl("http://ACE.internal", "http://ace.internal:80"));
assert.ok(!sameUrl("http://127.0.0.1:8989", "http://ace.internal:8989"));
});
+10 -1
View File
@@ -8,5 +8,14 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
}, },
"include": ["client.ts", "index.ts", "tools/index.ts"] "include": [
"client.ts",
"index.ts",
"tools/index.ts",
"provisions/hass.ts",
"provisions/probe.ts",
"provisions/connections.ts",
"provisions/mesh.ts",
"provisions/index.ts"
]
} }
+6 -6
View File
@@ -29,7 +29,7 @@
"stream.stopped" "stream.stopped"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/icecast/broker" "broker": "${dir:mesh-state}/broker"
}, },
"listens": [ "listens": [
{ {
@@ -44,8 +44,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/icecast", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
@@ -91,7 +91,7 @@
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/icecast/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
@@ -102,8 +102,8 @@
"name": "mesh-icecast", "name": "mesh-icecast",
"network": "icecast", "network": "icecast",
"volumes": [ "volumes": [
"/var/lib/mesh/icecast/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/icecast/config.json:/run/config/config.json:ro" "${dir:mesh-state}/config.json:/run/config/config.json:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
+6 -6
View File
@@ -11,7 +11,7 @@
"container-runtime" "container-runtime"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/influxdb/broker", "broker": "${dir:mesh-state}/broker",
"admin": "${dir:state}/admin.secret", "admin": "${dir:state}/admin.secret",
"admin-token": "${dir:state}/admin-token.secret" "admin-token": "${dir:state}/admin-token.secret"
}, },
@@ -42,8 +42,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/influxdb", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
@@ -96,7 +96,7 @@
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/influxdb/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
@@ -107,8 +107,8 @@
"name": "mesh-influxdb", "name": "mesh-influxdb",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/influxdb/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/influxdb/config.json:/run/config/config.json:ro", "${dir:mesh-state}/config.json:/run/config/config.json:ro",
"${dir:state}/admin-token.secret:/run/secrets/admin-token:ro", "${dir:state}/admin-token.secret:/run/secrets/admin-token:ro",
"${dir:grants}:${dir:grants}:ro" "${dir:grants}:${dir:grants}:ro"
], ],
+11 -11
View File
@@ -26,13 +26,13 @@
} }
}, },
"binds": { "binds": {
"mongodb-database": "/var/lib/invoicing/database.json", "mongodb-database": "${dir:state}/database.json",
"s3-bucket": "/var/lib/invoicing/store.json", "s3-bucket": "${dir:state}/store.json",
"route": "/var/lib/invoicing/route.json" "route": "${dir:state}/route.json"
}, },
"secrets": { "secrets": {
"mongodb-database": "/var/lib/invoicing/database.secret", "mongodb-database": "${dir:state}/database.secret",
"s3-bucket": "/var/lib/invoicing/store.secret" "s3-bucket": "${dir:state}/store.secret"
}, },
"listens": [ "listens": [
{ {
@@ -54,19 +54,19 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/invoicing", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/invoicing", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "api-env", "id": "api-env",
"type": "file", "type": "file",
"path": "/var/lib/invoicing/api.env", "path": "${dir:state}/api.env",
"mode": "0600", "mode": "0600",
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_BUCKET=mesh-novox-invoice\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n" "content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_BUCKET=mesh-novox-invoice\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n"
}, },
@@ -103,7 +103,7 @@
"GID": "2201" "GID": "2201"
}, },
"env-file": [ "env-file": [
"/var/lib/invoicing/api.env" "${dir:state}/api.env"
], ],
"ports": [ "ports": [
"9000" "9000"
-24
View File
@@ -1,24 +0,0 @@
# jackett's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/jackett
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/jackett/dist /app/modules/jackett/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/jackett/dist/tools/index.js
-136
View File
@@ -1,136 +0,0 @@
// The Jackett API client — jackett's own code, living in the module (novox/hq ADR 0039). Jackett is
// an indexer proxy: it normalises many torrent trackers behind one Torznab surface. This client
// talks its /api/v2.0 REST API, and only jackett's tools import it.
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
export interface JackettIndexer {
id: string;
name: string;
type: string; // "public" | "private" | "semi-public"
configured: boolean;
siteLink?: string;
lastError?: string;
}
export interface JackettResult {
title: string;
tracker: string;
category?: string;
size: number;
seeders?: number;
peers?: number;
publishDate?: string;
link?: 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 {}; }
}
export class JackettClient {
readonly baseUrl: string;
constructor(
url: string,
private readonly apiKey: string,
) {
this.baseUrl = url.replace(/\/$/, "");
}
/**
* Build from the module's resolved environment. Jackett's REST API is keyed, so both the URL and
* the key must be present. The key is read from the settings-merged config or MESH_JACKETT_API_KEY,
* or, failing those, discovered from Jackett's own ServerConfig.json under MESH_JACKETT_CONFIG_DIR
* — the file Jackett writes it to, as sonarr/radarr read theirs from config.xml — so a running
* server needs no key configured by hand and no secret has to be put in an assignment. Without a
* URL or key there is nothing to talk to, so this throws and the module contributes no tools
* rather than failing half-configured.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): JackettClient {
const cfg = meshConfig(env.MESH_JACKETT_CONFIG_FILE);
const url = cfg.url ?? env.MESH_JACKETT_URL;
const apiKey = cfg.apiKey ?? env.MESH_JACKETT_API_KEY
?? JackettClient.detectApiKey(env.MESH_JACKETT_CONFIG_DIR ?? "/config");
if (!url) throw new Error("no Jackett URL — set MESH_JACKETT_URL");
if (!apiKey) throw new Error("no Jackett API key — set MESH_JACKETT_API_KEY or make the config dir readable");
return new JackettClient(url, apiKey);
}
/** Discover the API key from Jackett's ServerConfig.json (the linuxserver image keeps it at
* <config>/Jackett/ServerConfig.json), falling back to null. */
static detectApiKey(configDir: string): string | null {
for (const file of [join(configDir, "Jackett", "ServerConfig.json"), join(configDir, "ServerConfig.json")]) {
if (!existsSync(file)) continue;
try {
const key = (JSON.parse(readFileSync(file, "utf8")) as { APIKey?: unknown }).APIKey;
if (typeof key === "string" && key) return key;
} catch { /* unreadable or mid-write: try the next, then give up */ }
}
return null;
}
private async get(path: string, params: Record<string, string> = {}): Promise<any> {
const url = new URL(`${this.baseUrl}${path}`);
url.searchParams.set("apikey", this.apiKey);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!res.ok) throw new Error(`Jackett API ${path}: ${res.status} ${await res.text()}`);
return res.json();
}
/**
* The configured indexers Jackett proxies. `configured=false` also lists the ones not set up.
* Read from the Torznab `t=indexers` feed, not /api/v2.0/indexers: that one is the web UI's and
* wants a login cookie (it answers an API-key request with a redirect), while the Torznab feed is
* what the key is for. The feed carries no last error, so `lastError` stays unset.
*/
async getIndexers(configuredOnly = true): Promise<JackettIndexer[]> {
const url = new URL(`${this.baseUrl}/api/v2.0/indexers/all/results/torznab/api`);
url.searchParams.set("apikey", this.apiKey);
url.searchParams.set("t", "indexers");
url.searchParams.set("configured", configuredOnly ? "true" : "false");
const res = await fetch(url.toString(), { headers: { Accept: "application/xml" } });
if (!res.ok) throw new Error(`Jackett API torznab t=indexers: ${res.status} ${await res.text()}`);
const xml = await res.text();
// Torznab reports failures (a wrong key among them) as 200 with an <error> body.
const err = xml.match(/<error code="(\d+)" description="([^"]*)"/);
if (err) throw new Error(`Jackett API torznab t=indexers: error ${err[1]} ${err[2]}`);
const text = (block: string, tag: string) =>
block.match(new RegExp(`<${tag}>([^<]*)</${tag}>`))?.[1];
const out: JackettIndexer[] = [];
for (const m of xml.matchAll(/<indexer id="([^"]+)" configured="([^"]+)">([\s\S]*?)<\/indexer>/g)) {
out.push({
id: m[1],
name: text(m[3], "title") ?? m[1],
type: text(m[3], "type") ?? "unknown",
configured: m[2] === "true",
siteLink: text(m[3], "link"),
});
}
return out;
}
/**
* A Torznab search across one indexer, or the "all" aggregate. Jackett returns a normalised JSON
* result set regardless of the underlying tracker, which is the whole point of the proxy.
*/
async search(query: string, indexer = "all", limit = 25): Promise<JackettResult[]> {
const raw = await this.get(`/api/v2.0/indexers/${encodeURIComponent(indexer)}/results`, { Query: query });
const results = Array.isArray(raw?.Results) ? raw.Results : [];
return results.slice(0, limit).map((r: any) => ({
title: r.Title,
tracker: r.Tracker ?? r.TrackerId ?? "unknown",
category: Array.isArray(r.CategoryDesc) ? r.CategoryDesc.join(", ") : r.CategoryDesc,
size: r.Size ?? 0,
seeders: r.Seeders,
peers: r.Peers,
publishDate: r.PublishDate,
link: r.Link ?? r.Details,
}));
}
}
-131
View File
@@ -1,131 +0,0 @@
{
"module": "jackett",
"version": "1",
"provides": [
{
"name": "jackett-api",
"scope": "mesh"
}
],
"serves": {
"jackett-api": {
"scheme": "http",
"port": 9117,
"url-base": ""
}
},
"capabilities": [
"container-runtime"
],
"listens": [
{
"name": "web",
"port": 9117,
"protocol": "tcp",
"from": "mesh",
"why": "the indexer proxy: its web UI, and the Torznab feeds the *arr apps search through, which other modules reach as jackett-api"
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/jackett",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "config",
"type": "directory",
"mode": "0700",
"owner": "1000:1000"
},
{
"id": "server",
"type": "container",
"name": "jackett",
"image": "lscr.io/linuxserver/jackett@sha256:7b19f4f6ac33d855ca9226600ecbd096ee678f66da28b13a7c09980b035ff583",
"env": {
"PUID": "1000",
"PGID": "1000",
"TZ": "Etc/UTC"
},
"ports": [
"9117"
],
"volumes": [
"${dir:config}:/config"
]
},
{
"id": "runtime-config",
"type": "file",
"path": "${dir:state}/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-jackett",
"network": "host",
"volumes": [
"/var/lib/mesh/jackett/broker:/run/secrets/broker:ro",
"${dir:state}/config.json:/run/config/config.json:ro",
"${dir:config}:/var/lib/jackett/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_JACKETT_URL": "http://127.0.0.1:${port:9117}",
"MESH_JACKETT_CONFIG_FILE": "/run/config/config.json",
"MESH_JACKETT_CONFIG_DIR": "/var/lib/jackett/config"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
}
],
"own-secrets": {
"broker": "/var/lib/mesh/jackett/broker"
},
"requires": [
"route"
],
"contributes": {
"route": {
"label": "indexers",
"endpoint": "web"
}
},
"binds": {
"route": "${dir:state}/route.json"
},
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
}
-14
View File
@@ -1,14 +0,0 @@
{
"name": "@novox/module-jackett",
"version": "0.1.0",
"description": "jackett — indexer proxy. Its API client and tools live here (novox/hq ADR 0039).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
-48
View File
@@ -1,48 +0,0 @@
// jackett's tools — its own code (novox/hq ADR 0039), importing jackett's client. Jackett has
// nothing worth watching (an indexer proxy answers queries; it has no timeline of its own), so it
// is a tools-only module: no events entrypoint, no broker. What is useful is asking it things.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { JackettClient } from "../client.js";
export function getJackettTools(jackett: JackettClient): ToolDefinition[] {
return [
{
name: "jackett_indexers",
description: "List the indexers Jackett proxies, with their type and site.",
input: { all: { type: "boolean", description: "include indexers not yet configured (default false)" } },
run: async (args) => {
const indexers = await jackett.getIndexers(!args.all);
return { count: indexers.length, indexers };
},
},
{
name: "jackett_search",
description: "Torznab search across Jackett's indexers, returning normalised torrent results.",
input: {
query: { type: "string", description: "the search query" },
indexer: { type: "string", description: 'an indexer id, or "all" to aggregate (default "all")' },
limit: { type: "number", description: "max results (default 25)" },
},
run: async (args) => {
const query = String(args.query);
const results = await jackett.search(
query,
args.indexer ? String(args.indexer) : "all",
args.limit ? Number(args.limit) : 25,
);
return { query, count: results.length, results };
},
},
];
}
// Only exposed when Jackett is configured; otherwise jackett contributes no tools rather than
// failing the whole runtime.
registerModuleTools("jackett", (env) => {
try {
return getJackettTools(JackettClient.fromEnv(env));
} catch {
return [];
}
});
-12
View File
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "tools/index.ts"]
}
+10 -10
View File
@@ -2,26 +2,26 @@
"module": "jira", "module": "jira",
"version": "1", "version": "1",
"own-secrets": { "own-secrets": {
"token": "/var/lib/jira/token", "token": "${dir:state}/token",
"broker": "/var/lib/mesh/jira/broker" "broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/jira", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/jira", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "config", "id": "config",
"type": "file", "type": "file",
"path": "/var/lib/jira/config.json", "path": "${dir:state}/config.json",
"merge": "json", "merge": "json",
"content": "{}", "content": "{}",
"mode": "0600" "mode": "0600"
@@ -32,9 +32,9 @@
"name": "mesh-runtime-jira", "name": "mesh-runtime-jira",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/jira/config.json:/run/config/config.json:ro", "${dir:state}/config.json:/run/config/config.json:ro",
"/var/lib/jira/token:/run/secrets/token:ro", "${dir:state}/token:/run/secrets/token:ro",
"/var/lib/mesh/jira/broker:/run/secrets/broker:ro" "${dir:mesh-state}/broker:/run/secrets/broker:ro"
], ],
"env": { "env": {
"MESH_JIRA_TOKEN_FILE": "/run/secrets/token", "MESH_JIRA_TOKEN_FILE": "/run/secrets/token",
+25 -25
View File
@@ -21,11 +21,11 @@
} }
}, },
"binds": { "binds": {
"postgres-database": "/var/lib/keycloak/database.json", "postgres-database": "${dir:state}/database.json",
"route": "/var/lib/keycloak/route.json" "route": "${dir:state}/route.json"
}, },
"secrets": { "secrets": {
"postgres-database": "/var/lib/keycloak/database.secret" "postgres-database": "${dir:state}/database.secret"
}, },
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
@@ -51,49 +51,49 @@
"oidc-client": { "oidc-client": {
"authorization-path": "/protocol/openid-connect/auth", "authorization-path": "/protocol/openid-connect/auth",
"token-path": "/protocol/openid-connect/token", "token-path": "/protocol/openid-connect/token",
"userinfo-path": "/protocol/openid-connect/userinfo" "userinfo-path": "/protocol/openid-connect/userinfo",
"issuer": "${setting:issuer}"
} }
}, },
"receives": { "receives": {
"oidc-client": "/var/lib/keycloak/grants/mesh.json" "oidc-client": "${dir:grants}/mesh.json"
}, },
"grants": { "grants": {
"oidc-client": "/var/lib/keycloak/grants" "oidc-client": "${dir:grants}"
}, },
"own-secrets": { "own-secrets": {
"admin": "/var/lib/keycloak/admin.secret", "admin": "${dir:state}/admin.secret",
"broker": "/var/lib/mesh/keycloak/broker" "broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/keycloak", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/keycloak", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "grants", "id": "grants",
"type": "directory", "type": "directory",
"path": "/var/lib/keycloak/grants",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "admin-env", "id": "admin-env",
"type": "file", "type": "file",
"path": "/var/lib/keycloak/admin.env", "path": "${dir:state}/admin.env",
"mode": "0600", "mode": "0600",
"content": "KEYCLOAK_ADMIN=admin\nKEYCLOAK_ADMIN_PASSWORD=${secret:admin}\n" "content": "KEYCLOAK_ADMIN=admin\nKEYCLOAK_ADMIN_PASSWORD=${secret:admin}\n"
}, },
{ {
"id": "database-env", "id": "database-env",
"type": "file", "type": "file",
"path": "/var/lib/keycloak/database.env", "path": "${dir:state}/database.env",
"mode": "0600", "mode": "0600",
"content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nKC_DB_USERNAME=${bound:postgres-database:as}\nKC_DB_PASSWORD=${secret:postgres-database}\n" "content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nKC_DB_USERNAME=${bound:postgres-database:as}\nKC_DB_PASSWORD=${secret:postgres-database}\n"
}, },
@@ -105,7 +105,7 @@
{ {
"id": "hostname", "id": "hostname",
"type": "file", "type": "file",
"path": "/var/lib/keycloak/hostname.env", "path": "${dir:state}/hostname.env",
"mode": "0644", "mode": "0644",
"content": "KC_HOSTNAME=https://${bound:route:name}\n" "content": "KC_HOSTNAME=https://${bound:route:name}\n"
}, },
@@ -125,9 +125,9 @@
"KC_PROXY_HEADERS": "xforwarded" "KC_PROXY_HEADERS": "xforwarded"
}, },
"env-file": [ "env-file": [
"/var/lib/keycloak/admin.env", "${dir:state}/admin.env",
"/var/lib/keycloak/database.env", "${dir:state}/database.env",
"/var/lib/keycloak/hostname.env" "${dir:state}/hostname.env"
], ],
"ports": [ "ports": [
"8080" "8080"
@@ -140,7 +140,7 @@
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/keycloak/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
@@ -151,17 +151,17 @@
"name": "mesh-keycloak", "name": "mesh-keycloak",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/keycloak/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/keycloak/config.json:/run/config/config.json:ro", "${dir:mesh-state}/config.json:/run/config/config.json:ro",
"/var/lib/keycloak/admin.secret:/run/secrets/admin:ro", "${dir:state}/admin.secret:/run/secrets/admin:ro",
"/var/lib/keycloak/grants:/var/lib/keycloak/grants:ro" "${dir:grants}:${dir:grants}:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}", "MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}",
"MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json", "MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json",
"MESH_KEYCLOAK_PASSWORD_FILE": "/run/secrets/admin", "MESH_KEYCLOAK_PASSWORD_FILE": "/run/secrets/admin",
"MESH_RECEIVES": "/var/lib/keycloak/grants/mesh.json" "MESH_RECEIVES": "${dir:grants}/mesh.json"
}, },
"restart-on": [ "restart-on": [
"runtime-config" "runtime-config"
+6 -6
View File
@@ -27,7 +27,7 @@
"own-secrets": { "own-secrets": {
"server-password": "${dir:state}/server-password.secret", "server-password": "${dir:state}/server-password.secret",
"openai-api-key": "${dir:state}/openai-api-key.secret", "openai-api-key": "${dir:state}/openai-api-key.secret",
"broker": "/var/lib/mesh/letta/broker" "broker": "${dir:mesh-state}/broker"
}, },
"listens": [ "listens": [
{ {
@@ -42,8 +42,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/letta", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
@@ -80,7 +80,7 @@
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/letta/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{\n \"password\": \"${secret:server-password}\"\n}\n", "content": "{\n \"password\": \"${secret:server-password}\"\n}\n",
"merge": "json" "merge": "json"
@@ -91,8 +91,8 @@
"name": "mesh-letta", "name": "mesh-letta",
"network": "letta", "network": "letta",
"volumes": [ "volumes": [
"/var/lib/mesh/letta/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/letta/config.json:/run/config/config.json:ro" "${dir:mesh-state}/config.json:/run/config/config.json:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
+9 -7
View File
@@ -10,7 +10,7 @@
], ],
"consumes": [], "consumes": [],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/lidarr/broker" "broker": "${dir:mesh-state}/broker"
}, },
"listens": [ "listens": [
{ {
@@ -23,10 +23,12 @@
], ],
"accesses": [ "accesses": [
{ {
"id": "music",
"path": "/services/media/music", "path": "/services/media/music",
"mode": "read-write" "mode": "read-write"
}, },
{ {
"id": "downloads",
"path": "/services/media/downloads", "path": "/services/media/downloads",
"mode": "read-write" "mode": "read-write"
} }
@@ -35,8 +37,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/lidarr", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "config", "id": "config",
@@ -60,8 +62,8 @@
], ],
"volumes": [ "volumes": [
"/services/lidarr/config:/config", "/services/lidarr/config:/config",
"/services/media/music:/music", "${access:music}:/music",
"/services/media/downloads:/downloads" "${access:downloads}:/downloads"
] ]
}, },
{ {
@@ -70,7 +72,7 @@
"name": "mesh-lidarr", "name": "mesh-lidarr",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/lidarr/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/services/lidarr/config:/var/lib/lidarr/config:ro" "/services/lidarr/config:/var/lib/lidarr/config:ro"
], ],
"env": { "env": {
@@ -91,7 +93,7 @@
} }
}, },
"binds": { "binds": {
"route": "/var/lib/mesh/lidarr/route.json" "route": "${dir:mesh-state}/route.json"
}, },
"build": { "build": {
"on": [ "on": [
+4 -5
View File
@@ -6,25 +6,24 @@
"model-access" "model-access"
], ],
"binds": { "binds": {
"model-access": "/var/lib/local-model-consumer/model.json" "model-access": "${dir:state}/model.json"
}, },
"resources": [ "resources": [
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/local-model-consumer", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "config", "id": "config",
"type": "directory", "type": "directory",
"path": "/var/lib/local-model-consumer/config",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "openai-env", "id": "openai-env",
"type": "file", "type": "file",
"path": "/var/lib/local-model-consumer/config/openai.env", "path": "${dir:config}/openai.env",
"mode": "0600", "mode": "0600",
"content": "OPENAI_BASE_URL=http://${bound:model-access:at}:${bound:model-access:port}/v1\nOPENAI_MODEL=${bound:model-access:model}\nOPENAI_API_KEY=local\n" "content": "OPENAI_BASE_URL=http://${bound:model-access:at}:${bound:model-access:port}/v1\nOPENAI_MODEL=${bound:model-access:model}\nOPENAI_API_KEY=local\n"
} }
+8 -7
View File
@@ -138,14 +138,14 @@
} }
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/mailu/broker" "broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/mailu", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
@@ -467,7 +467,7 @@
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/mailu/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
@@ -478,10 +478,10 @@
"name": "mesh-mailu", "name": "mesh-mailu",
"network": "mailu", "network": "mailu",
"volumes": [ "volumes": [
"/var/lib/mesh/mailu/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:state}/api-token.secret:/run/secrets/api-token:ro", "${dir:state}/api-token.secret:/run/secrets/api-token:ro",
"${dir:grants}:${dir:grants}:ro", "${dir:grants}:${dir:grants}:ro",
"/var/lib/mesh/mailu/config.json:/run/config/config.json:ro", "${dir:mesh-state}/config.json:/run/config/config.json:ro",
"/var/run/docker.sock:/var/run/docker.sock" "/var/run/docker.sock:/var/run/docker.sock"
], ],
"env": { "env": {
@@ -552,7 +552,8 @@
], ],
"serves": { "serves": {
"smtp": { "smtp": {
"port": 587 "port": 587,
"domain": "${setting:domain}"
} }
}, },
"receives": { "receives": {
+2 -2
View File
@@ -17,8 +17,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/marrytts", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "server", "id": "server",
+11 -11
View File
@@ -20,13 +20,13 @@
} }
}, },
"binds": { "binds": {
"postgres-database": "/var/lib/mesh-catalog/database.json" "postgres-database": "${dir:state}/database.json"
}, },
"secrets": { "secrets": {
"postgres-database": "/var/lib/mesh-catalog/database.secret" "postgres-database": "${dir:state}/database.secret"
}, },
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/mesh-catalog/broker" "broker": "${dir:mesh-state}/broker"
}, },
"consumes": [ "consumes": [
"mesh-build-machine.built", "mesh-build-machine.built",
@@ -43,19 +43,19 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/mesh-catalog", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh-catalog", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "database-url", "id": "database-url",
"type": "file", "type": "file",
"path": "/var/lib/mesh-catalog/database.url", "path": "${dir:state}/database.url",
"mode": "0600", "mode": "0600",
"content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n" "content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n"
}, },
@@ -65,9 +65,9 @@
"name": "mesh-catalog", "name": "mesh-catalog",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/mesh-catalog/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh-catalog:/run/state", "${dir:state}:/run/state",
"/var/lib/mesh-catalog/database.url:/run/secrets/database-url:ro" "${dir:state}/database.url:/run/secrets/database-url:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
+4 -4
View File
@@ -9,7 +9,7 @@
"*" "*"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/mesh-console/broker" "broker": "${dir:mesh-state}/broker"
}, },
"listens": [ "listens": [
{ {
@@ -24,8 +24,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/mesh-console", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "server", "id": "server",
@@ -40,7 +40,7 @@
"MESH_CONSOLE_LISTEN": "127.0.0.1:${port:4270}" "MESH_CONSOLE_LISTEN": "127.0.0.1:${port:4270}"
}, },
"volumes": [ "volumes": [
"/var/lib/mesh/mesh-console/broker:/run/secrets/broker:ro" "${dir:mesh-state}/broker:/run/secrets/broker:ro"
], ],
"artifact": "runtime" "artifact": "runtime"
} }
+14 -17
View File
@@ -21,44 +21,41 @@
"mesh-vault.secret.deprovisioned" "mesh-vault.secret.deprovisioned"
], ],
"receives": { "receives": {
"secret": "/var/lib/mesh-vault/grants/mesh.json" "secret": "${dir:grants}/mesh.json"
}, },
"grants": { "grants": {
"secret": "/var/lib/mesh-vault/grants" "secret": "${dir:grants}"
}, },
"keeps": "/var/lib/mesh-vault/root", "keeps": "/var/lib/mesh-vault/root",
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/mesh-vault/broker" "broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/mesh-vault", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh-vault", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "grants", "id": "grants",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh-vault/grants",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "ledger", "id": "ledger",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh-vault/ledger",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "root", "id": "root",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh-vault/root",
"mode": "0700" "mode": "0700"
}, },
{ {
@@ -67,16 +64,16 @@
"name": "mesh-vault", "name": "mesh-vault",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/mesh-vault/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh-vault/grants:/var/lib/mesh-vault/grants:ro", "${dir:grants}:${dir:grants}:ro",
"/var/lib/mesh-vault/ledger:/var/lib/mesh-vault/ledger", "${dir:ledger}:${dir:ledger}",
"/var/lib/mesh-vault/root:/var/lib/mesh-vault/root:ro" "${dir:root}:${dir:root}:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "/var/lib/mesh-vault/grants/mesh.json", "MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_VAULT_LEDGER": "/var/lib/mesh-vault/ledger", "MESH_VAULT_LEDGER": "${dir:ledger}",
"MESH_VAULT_ROOT": "/var/lib/mesh-vault/root" "MESH_VAULT_ROOT": "${dir:root}"
}, },
"artifact": "runtime" "artifact": "runtime"
} }
+15 -16
View File
@@ -53,38 +53,37 @@
} }
}, },
"receives": { "receives": {
"s3-bucket": "/var/lib/minio/grants/mesh.json" "s3-bucket": "${dir:grants}/mesh.json"
}, },
"grants": { "grants": {
"s3-bucket": "/var/lib/minio/grants" "s3-bucket": "${dir:grants}"
}, },
"own-secrets": { "own-secrets": {
"root": "/var/lib/minio/root.secret", "root": "${dir:state}/root.secret",
"broker": "/var/lib/mesh/minio/broker" "broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/minio", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/minio", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "grants", "id": "grants",
"type": "directory", "type": "directory",
"path": "/var/lib/minio/grants",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "root-env", "id": "root-env",
"type": "file", "type": "file",
"path": "/var/lib/minio/root.env", "path": "${dir:state}/root.env",
"mode": "0600", "mode": "0600",
"content": "MINIO_ROOT_USER=meshroot\nMINIO_BROWSER_REDIRECT_URL=https://${bound:route:name-console}\n" "content": "MINIO_ROOT_USER=meshroot\nMINIO_BROWSER_REDIRECT_URL=https://${bound:route:name-console}\n"
}, },
@@ -112,7 +111,7 @@
":9001" ":9001"
], ],
"env-file": [ "env-file": [
"/var/lib/minio/root.env" "${dir:state}/root.env"
], ],
"ports": [ "ports": [
"9000", "9000",
@@ -120,7 +119,7 @@
], ],
"volumes": [ "volumes": [
"/var/lib/minio-store:/data", "/var/lib/minio-store:/data",
"/var/lib/minio/root.secret:/run/secrets/root:ro" "${dir:state}/root.secret:/run/secrets/root:ro"
], ],
"env": { "env": {
"MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", "MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root",
@@ -133,9 +132,9 @@
"name": "mesh-minio", "name": "mesh-minio",
"network": "minio-net", "network": "minio-net",
"volumes": [ "volumes": [
"/var/lib/mesh/minio/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/minio/grants:/var/lib/minio/grants:ro", "${dir:grants}:${dir:grants}:ro",
"/var/lib/minio/root.secret:/run/secrets/root:ro" "${dir:state}/root.secret:/run/secrets/root:ro"
], ],
"env": { "env": {
"MESH_MINIO_ENDPOINT": "http://minio:9000", "MESH_MINIO_ENDPOINT": "http://minio:9000",
@@ -143,7 +142,7 @@
"MESH_MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", "MESH_MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root",
"MESH_MINIO_REGION": "eu-west", "MESH_MINIO_REGION": "eu-west",
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "/var/lib/minio/grants/mesh.json" "MESH_RECEIVES": "${dir:grants}/mesh.json"
}, },
"artifact": "runtime" "artifact": "runtime"
} }
+11 -11
View File
@@ -14,34 +14,34 @@
} }
}, },
"binds": { "binds": {
"postgres-database": "/var/lib/model-usage/database.json" "postgres-database": "${dir:state}/database.json"
}, },
"secrets": { "secrets": {
"postgres-database": "/var/lib/model-usage/database.secret" "postgres-database": "${dir:state}/database.secret"
}, },
"consumes": [ "consumes": [
"*.usage.*" "*.usage.*"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/model-usage/broker" "broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/model-usage", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/model-usage", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "database-url", "id": "database-url",
"type": "file", "type": "file",
"path": "/var/lib/model-usage/database.url", "path": "${dir:state}/database.url",
"mode": "0600", "mode": "0600",
"content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n" "content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n"
}, },
@@ -52,9 +52,9 @@
"image": "mesh-runtime-model-usage@sha256:0000000000000000000000000000000000000000000000000000000000000000", "image": "mesh-runtime-model-usage@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/model-usage/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/model-usage:/run/state", "${dir:state}:/run/state",
"/var/lib/model-usage/database.url:/run/secrets/database-url:ro" "${dir:state}/database.url:/run/secrets/database-url:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
+4 -4
View File
@@ -40,15 +40,15 @@
}, },
"own-secrets": { "own-secrets": {
"root": "${dir:state}/root.secret", "root": "${dir:state}/root.secret",
"broker": "/var/lib/mesh/mongodb/broker" "broker": "${dir:mesh-state}/broker"
}, },
"secrets-owner": "999:999", "secrets-owner": "999:999",
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/mongodb", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
@@ -95,7 +95,7 @@
"name": "mesh-mongodb", "name": "mesh-mongodb",
"network": "mongodb", "network": "mongodb",
"volumes": [ "volumes": [
"/var/lib/mesh/mongodb/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:grants}:${dir:grants}:ro", "${dir:grants}:${dir:grants}:ro",
"${dir:state}/root.secret:/run/secrets/root:ro" "${dir:state}/root.secret:/run/secrets/root:ro"
], ],
+7 -7
View File
@@ -32,8 +32,8 @@
"mqtt-topic": "${dir:grants}" "mqtt-topic": "${dir:grants}"
}, },
"own-secrets": { "own-secrets": {
"admin": "/var/lib/mesh/mosquitto/admin", "admin": "${dir:mesh-state}/admin",
"broker": "/var/lib/mesh/mosquitto/broker" "broker": "${dir:mesh-state}/broker"
}, },
"listens": [ "listens": [
{ {
@@ -55,8 +55,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/mosquitto", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
@@ -95,7 +95,7 @@
"run-once": true, "run-once": true,
"volumes": [ "volumes": [
"${dir:data}:/mosquitto/data", "${dir:data}:/mosquitto/data",
"/var/lib/mesh/mosquitto/admin:/run/secrets/admin:ro" "${dir:mesh-state}/admin:/run/secrets/admin:ro"
], ],
"env": { "env": {
"MESH_PROVISION_MQTT": "mosquitto:1883", "MESH_PROVISION_MQTT": "mosquitto:1883",
@@ -130,9 +130,9 @@
"name": "mesh-mosquitto", "name": "mesh-mosquitto",
"network": "mosquitto", "network": "mosquitto",
"volumes": [ "volumes": [
"/var/lib/mesh/mosquitto/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:grants}:${dir:grants}:ro", "${dir:grants}:${dir:grants}:ro",
"/var/lib/mesh/mosquitto/admin:/run/secrets/admin:ro" "${dir:mesh-state}/admin:/run/secrets/admin:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
+4 -4
View File
@@ -38,14 +38,14 @@
}, },
"own-secrets": { "own-secrets": {
"sa": "${dir:state}/sa.secret", "sa": "${dir:state}/sa.secret",
"broker": "/var/lib/mesh/mssql/broker" "broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/mssql", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
@@ -99,7 +99,7 @@
"name": "mesh-mssql", "name": "mesh-mssql",
"network": "mssql", "network": "mssql",
"volumes": [ "volumes": [
"/var/lib/mesh/mssql/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:grants}:/var/lib/mssql/grants:ro", "${dir:grants}:/var/lib/mssql/grants:ro",
"${dir:state}/sa.secret:/run/secrets/sa:ro" "${dir:state}/sa.secret:/run/secrets/sa:ro"
], ],
+8 -8
View File
@@ -18,14 +18,14 @@
} }
}, },
"binds": { "binds": {
"postgres-database": "/var/lib/n8n/database.json", "postgres-database": "${dir:state}/database.json",
"route": "/var/lib/n8n/route.json" "route": "${dir:state}/route.json"
}, },
"secrets": { "secrets": {
"postgres-database": "/var/lib/n8n/database.secret" "postgres-database": "${dir:state}/database.secret"
}, },
"own-secrets": { "own-secrets": {
"basic-auth": "/var/lib/n8n/basic-auth.secret" "basic-auth": "${dir:state}/basic-auth.secret"
}, },
"listens": [ "listens": [
{ {
@@ -40,8 +40,8 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/n8n", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "data", "id": "data",
@@ -53,7 +53,7 @@
{ {
"id": "server-env", "id": "server-env",
"type": "file", "type": "file",
"path": "/var/lib/n8n/server.env", "path": "${dir:state}/server.env",
"mode": "0600", "mode": "0600",
"content": "N8N_HOST=${bound:route:name}\nN8N_PORT=5678\nN8N_PROTOCOL=https\nWEBHOOK_URL=https://${bound:route:name}/\nN8N_BASIC_AUTH_ACTIVE=true\nN8N_BASIC_AUTH_USER=admin\nN8N_BASIC_AUTH_PASSWORD=${secret:basic-auth}\nNODE_FUNCTION_ALLOW_BUILTIN=*\nNODE_FUNCTION_ALLOW_EXTERNAL=*\nDB_TYPE=postgresdb\nDB_POSTGRESDB_HOST=${bound:postgres-database:at}\nDB_POSTGRESDB_PORT=${bound:postgres-database:port}\nDB_POSTGRESDB_DATABASE=${bound:postgres-database:as}\nDB_POSTGRESDB_USER=${bound:postgres-database:as}\nDB_POSTGRESDB_PASSWORD=${secret:postgres-database}\n" "content": "N8N_HOST=${bound:route:name}\nN8N_PORT=5678\nN8N_PROTOCOL=https\nWEBHOOK_URL=https://${bound:route:name}/\nN8N_BASIC_AUTH_ACTIVE=true\nN8N_BASIC_AUTH_USER=admin\nN8N_BASIC_AUTH_PASSWORD=${secret:basic-auth}\nNODE_FUNCTION_ALLOW_BUILTIN=*\nNODE_FUNCTION_ALLOW_EXTERNAL=*\nDB_TYPE=postgresdb\nDB_POSTGRESDB_HOST=${bound:postgres-database:at}\nDB_POSTGRESDB_PORT=${bound:postgres-database:port}\nDB_POSTGRESDB_DATABASE=${bound:postgres-database:as}\nDB_POSTGRESDB_USER=${bound:postgres-database:as}\nDB_POSTGRESDB_PASSWORD=${secret:postgres-database}\n"
}, },
@@ -69,7 +69,7 @@
"image": "n8nio/n8n@sha256:4846eb2f4b874ab04cde7fc1e249d2ddaec66e9aea64439beb2972cfea88e3c0", "image": "n8nio/n8n@sha256:4846eb2f4b874ab04cde7fc1e249d2ddaec66e9aea64439beb2972cfea88e3c0",
"network": "n8n", "network": "n8n",
"env-file": [ "env-file": [
"/var/lib/n8n/server.env" "${dir:state}/server.env"
], ],
"ports": [ "ports": [
"5678" "5678"
+2 -1
View File
@@ -62,13 +62,14 @@
"volumes": [ "volumes": [
"/var/lib/mesh-broker-nats:/data", "/var/lib/mesh-broker-nats:/data",
"/var/lib/nats-module/conf:/etc/nats:ro", "/var/lib/nats-module/conf:/etc/nats:ro",
"/var/lib/mesh-broker-tls:/tls:ro" "${access:tls}:/tls:ro"
], ],
"artifact": "server" "artifact": "server"
} }
], ],
"accesses": [ "accesses": [
{ {
"id": "tls",
"path": "/var/lib/mesh-broker-tls", "path": "/var/lib/mesh-broker-tls",
"mode": "read" "mode": "read"
} }
+6 -6
View File
@@ -31,7 +31,7 @@
], ],
"own-secrets": { "own-secrets": {
"admin": "${dir:state}/admin.secret", "admin": "${dir:state}/admin.secret",
"broker": "/var/lib/mesh/nextcloud/broker" "broker": "${dir:mesh-state}/broker"
}, },
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
@@ -49,8 +49,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/nextcloud", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
@@ -90,7 +90,7 @@
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/nextcloud/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
@@ -101,8 +101,8 @@
"name": "mesh-nextcloud", "name": "mesh-nextcloud",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/nextcloud/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/nextcloud/config.json:/run/config/config.json:ro", "${dir:mesh-state}/config.json:/run/config/config.json:ro",
"${dir:state}/admin.secret:/run/secrets/admin:ro", "${dir:state}/admin.secret:/run/secrets/admin:ro",
"/var/run/docker.sock:/var/run/docker.sock" "/var/run/docker.sock:/var/run/docker.sock"
], ],
+9 -9
View File
@@ -5,9 +5,9 @@
"flows.deployed" "flows.deployed"
], ],
"own-secrets": { "own-secrets": {
"admin": "/var/lib/mesh/nodered/admin", "admin": "${dir:mesh-state}/admin",
"api-token": "/var/lib/mesh/nodered/api-token", "api-token": "${dir:mesh-state}/api-token",
"broker": "/var/lib/mesh/nodered/broker" "broker": "${dir:mesh-state}/broker"
}, },
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
@@ -25,8 +25,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/nodered", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
@@ -90,7 +90,7 @@
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/nodered/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{\n \"token\": \"${secret:api-token}\"\n}\n" "content": "{\n \"token\": \"${secret:api-token}\"\n}\n"
}, },
@@ -100,8 +100,8 @@
"name": "mesh-nodered", "name": "mesh-nodered",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/nodered/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/nodered/config.json:/run/config/config.json:ro" "${dir:mesh-state}/config.json:/run/config/config.json:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
@@ -120,7 +120,7 @@
"network": "host", "network": "host",
"run-once": true, "run-once": true,
"volumes": [ "volumes": [
"/var/lib/mesh/nodered/config.json:/run/config/config.json:ro", "${dir:mesh-state}/config.json:/run/config/config.json:ro",
"${dir:written}:/var/lib/nodered-provisions", "${dir:written}:/var/lib/nodered-provisions",
"${dir:state}/mqtt-topic.json:/run/provisions/mqtt-topic.json:ro", "${dir:state}/mqtt-topic.json:/run/provisions/mqtt-topic.json:ro",
"${dir:state}/mqtt-topic.secret:/run/provisions/mqtt-topic.secret:ro", "${dir:state}/mqtt-topic.secret:/run/provisions/mqtt-topic.secret:ro",
+10 -9
View File
@@ -10,8 +10,8 @@
], ],
"consumes": [], "consumes": [],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/nzbget/broker", "broker": "${dir:mesh-state}/broker",
"password": "/var/lib/mesh/nzbget/password" "password": "${dir:mesh-state}/password"
}, },
"listens": [ "listens": [
{ {
@@ -24,6 +24,7 @@
], ],
"accesses": [ "accesses": [
{ {
"id": "downloads",
"path": "/services/media/downloads", "path": "/services/media/downloads",
"mode": "read-write" "mode": "read-write"
} }
@@ -32,8 +33,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/nzbget", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "config", "id": "config",
@@ -57,13 +58,13 @@
], ],
"volumes": [ "volumes": [
"/services/nzbget/config:/config", "/services/nzbget/config:/config",
"/services/media/downloads:/downloads" "${access:downloads}:/downloads"
] ]
}, },
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/nzbget/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
@@ -74,9 +75,9 @@
"name": "mesh-nzbget", "name": "mesh-nzbget",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/nzbget/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/nzbget/password:/run/secrets/password:ro", "${dir:mesh-state}/password:/run/secrets/password:ro",
"/var/lib/mesh/nzbget/config.json:/run/config/config.json:ro", "${dir:mesh-state}/config.json:/run/config/config.json:ro",
"/services/nzbget/config:/var/lib/nzbget/config:ro" "/services/nzbget/config:/var/lib/nzbget/config:ro"
], ],
"env": { "env": {
+9 -9
View File
@@ -9,8 +9,8 @@
"request.approved" "request.approved"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/ombi/broker", "broker": "${dir:mesh-state}/broker",
"api-key": "/var/lib/mesh/ombi/api-key" "api-key": "${dir:mesh-state}/api-key"
}, },
"listens": [ "listens": [
{ {
@@ -25,8 +25,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/ombi", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "config", "id": "config",
@@ -55,7 +55,7 @@
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/ombi/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
@@ -66,9 +66,9 @@
"name": "mesh-ombi", "name": "mesh-ombi",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/ombi/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/ombi/api-key:/run/secrets/api-key:ro", "${dir:mesh-state}/api-key:/run/secrets/api-key:ro",
"/var/lib/mesh/ombi/config.json:/run/config/config.json:ro", "${dir:mesh-state}/config.json:/run/config/config.json:ro",
"/services/ombi/config:/var/lib/ombi/config:ro" "/services/ombi/config:/var/lib/ombi/config:ro"
], ],
"env": { "env": {
@@ -94,7 +94,7 @@
} }
}, },
"binds": { "binds": {
"route": "/var/lib/mesh/ombi/route.json" "route": "${dir:mesh-state}/route.json"
}, },
"build": { "build": {
"on": [ "on": [
+5 -6
View File
@@ -9,22 +9,21 @@
"model-access" "model-access"
], ],
"binds": { "binds": {
"model-access": "/var/lib/openai-consumer/model.json" "model-access": "${dir:state}/model.json"
}, },
"secrets": { "secrets": {
"model-access": "/var/lib/openai-consumer/api-key" "model-access": "${dir:state}/api-key"
}, },
"resources": [ "resources": [
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/openai-consumer", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "config", "id": "config",
"type": "directory", "type": "directory",
"path": "/var/lib/openai-consumer/config",
"mode": "0700" "mode": "0700"
}, },
{ {
@@ -38,7 +37,7 @@
"/app/modules/openai-consumer/dist/apply/index.js" "/app/modules/openai-consumer/dist/apply/index.js"
], ],
"volumes": [ "volumes": [
"/var/lib/openai-consumer:/run/state" "${dir:state}:/run/state"
], ],
"env": { "env": {
"MESH_MODEL_ACCESS_SECRET_FILE": "/run/state/api-key", "MESH_MODEL_ACCESS_SECRET_FILE": "/run/state/api-key",
+3 -3
View File
@@ -15,7 +15,7 @@
} }
}, },
"binds": { "binds": {
"route": "/var/lib/photos-eef/route.json" "route": "${dir:state}/route.json"
}, },
"listens": [ "listens": [
{ {
@@ -30,8 +30,8 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/photos-eef", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "net", "id": "net",
+3 -3
View File
@@ -15,7 +15,7 @@
} }
}, },
"binds": { "binds": {
"route": "/var/lib/photos-filip/route.json" "route": "${dir:state}/route.json"
}, },
"listens": [ "listens": [
{ {
@@ -30,8 +30,8 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/photos-filip", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "net", "id": "net",
+9 -9
View File
@@ -22,13 +22,13 @@
} }
}, },
"binds": { "binds": {
"s3-bucket": "/var/lib/photos/store.json", "s3-bucket": "${dir:state}/store.json",
"mongodb-database": "/var/lib/photos/database.json", "mongodb-database": "${dir:state}/database.json",
"route": "/var/lib/photos/route.json" "route": "${dir:state}/route.json"
}, },
"secrets": { "secrets": {
"s3-bucket": "/var/lib/photos/store.secret", "s3-bucket": "${dir:state}/store.secret",
"mongodb-database": "/var/lib/photos/database.secret" "mongodb-database": "${dir:state}/database.secret"
}, },
"listens": [ "listens": [
{ {
@@ -50,13 +50,13 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/photos", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "server-env", "id": "server-env",
"type": "file", "type": "file",
"path": "/var/lib/photos/server.env", "path": "${dir:state}/server.env",
"mode": "0600", "mode": "0600",
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=admin\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_BUCKET=mesh-novox-photos\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\nMINIO_USE_SSL=false\n" "content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=admin\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_BUCKET=mesh-novox-photos\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\nMINIO_USE_SSL=false\n"
}, },
@@ -72,7 +72,7 @@
"image": "registry-api.novox.be/novox/photos-server@sha256:3f165acbbd1fd731b12fe798c95879c081a44b00e2c569ef7f47165f6a527201", "image": "registry-api.novox.be/novox/photos-server@sha256:3f165acbbd1fd731b12fe798c95879c081a44b00e2c569ef7f47165f6a527201",
"network": "photos", "network": "photos",
"env-file": [ "env-file": [
"/var/lib/photos/server.env" "${dir:state}/server.env"
], ],
"ports": [ "ports": [
"9000" "9000"
+16 -11
View File
@@ -13,8 +13,8 @@
"*.download.completed" "*.download.completed"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/plex/broker", "broker": "${dir:mesh-state}/broker",
"token": "/var/lib/mesh/plex/token" "token": "${dir:mesh-state}/token"
}, },
"listens": [ "listens": [
{ {
@@ -27,22 +27,27 @@
], ],
"accesses": [ "accesses": [
{ {
"id": "movies",
"path": "/services/media/movies", "path": "/services/media/movies",
"mode": "read" "mode": "read"
}, },
{ {
"id": "series",
"path": "/services/media/series", "path": "/services/media/series",
"mode": "read" "mode": "read"
}, },
{ {
"id": "anime",
"path": "/services/media/anime", "path": "/services/media/anime",
"mode": "read" "mode": "read"
}, },
{ {
"id": "music",
"path": "/services/media/music", "path": "/services/media/music",
"mode": "read" "mode": "read"
}, },
{ {
"id": "audiobooks",
"path": "/services/media/audiobooks", "path": "/services/media/audiobooks",
"mode": "read" "mode": "read"
} }
@@ -51,8 +56,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/plex", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "config", "id": "config",
@@ -82,11 +87,11 @@
"volumes": [ "volumes": [
"/services/plex/config:/config", "/services/plex/config:/config",
"/services/plex/transcode:/transcode", "/services/plex/transcode:/transcode",
"/services/media/movies:/movies", "${access:movies}:/movies",
"/services/media/series:/series", "${access:series}:/series",
"/services/media/anime:/anime", "${access:anime}:/anime",
"/services/media/music:/music", "${access:music}:/music",
"/services/media/audiobooks:/audiobooks" "${access:audiobooks}:/audiobooks"
] ]
}, },
{ {
@@ -95,8 +100,8 @@
"name": "mesh-plex", "name": "mesh-plex",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/plex/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/plex/token:/run/secrets/token:ro", "${dir:mesh-state}/token:/run/secrets/token:ro",
"/services/plex/config:/var/lib/plex/config:ro" "/services/plex/config:/var/lib/plex/config:ro"
], ],
"env": { "env": {
+7 -7
View File
@@ -25,8 +25,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/portainer", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "data", "id": "data",
@@ -50,7 +50,7 @@
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/portainer/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
@@ -61,8 +61,8 @@
"name": "mesh-portainer", "name": "mesh-portainer",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/portainer/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/portainer/config.json:/run/config/config.json:ro" "${dir:mesh-state}/config.json:/run/config/config.json:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
@@ -76,7 +76,7 @@
} }
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/portainer/broker" "broker": "${dir:mesh-state}/broker"
}, },
"build": { "build": {
"on": [ "on": [
@@ -109,6 +109,6 @@
} }
}, },
"binds": { "binds": {
"route": "/var/lib/mesh/portainer/route.json" "route": "${dir:mesh-state}/route.json"
} }
} }
+13 -14
View File
@@ -42,32 +42,31 @@
} }
}, },
"receives": { "receives": {
"postgres-database": "/var/lib/postgres/grants/mesh.json" "postgres-database": "${dir:grants}/mesh.json"
}, },
"grants": { "grants": {
"postgres-database": "/var/lib/postgres/grants" "postgres-database": "${dir:grants}"
}, },
"own-secrets": { "own-secrets": {
"superuser": "/var/lib/postgres/superuser.secret", "superuser": "${dir:state}/superuser.secret",
"broker": "/var/lib/mesh/postgres/broker" "broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/postgres", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/postgres", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "grants", "id": "grants",
"type": "directory", "type": "directory",
"path": "/var/lib/postgres/grants",
"mode": "0700" "mode": "0700"
}, },
{ {
@@ -91,7 +90,7 @@
], ],
"volumes": [ "volumes": [
"/var/lib/mesh-store:/var/lib/postgresql/data", "/var/lib/mesh-store:/var/lib/postgresql/data",
"/var/lib/postgres/superuser.secret:/run/secrets/superuser:ro" "${dir:state}/superuser.secret:/run/secrets/superuser:ro"
] ]
}, },
{ {
@@ -100,16 +99,16 @@
"name": "mesh-postgres", "name": "mesh-postgres",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/postgres/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/postgres/grants:/var/lib/postgres/grants:ro", "${dir:grants}:${dir:grants}:ro",
"/var/lib/postgres/superuser.secret:/run/secrets/superuser:ro" "${dir:state}/superuser.secret:/run/secrets/superuser:ro"
], ],
"env": { "env": {
"MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:${port:5432}/postgres?sslmode=disable", "MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:${port:5432}/postgres?sslmode=disable",
"MESH_PROVISION_POSTGRES_PORT": "${seat:mesh-store:5432}", "MESH_PROVISION_POSTGRES_PORT": "${seat:mesh-store:5432}",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/superuser", "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/superuser",
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "/var/lib/postgres/grants/mesh.json" "MESH_RECEIVES": "${dir:grants}/mesh.json"
}, },
"artifact": "runtime" "artifact": "runtime"
} }
+10 -9
View File
@@ -11,8 +11,8 @@
], ],
"consumes": [], "consumes": [],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/qbittorrent/broker", "broker": "${dir:mesh-state}/broker",
"password": "/var/lib/mesh/qbittorrent/password" "password": "${dir:mesh-state}/password"
}, },
"listens": [ "listens": [
{ {
@@ -25,6 +25,7 @@
], ],
"accesses": [ "accesses": [
{ {
"id": "downloads",
"path": "/services/media/downloads", "path": "/services/media/downloads",
"mode": "read-write" "mode": "read-write"
} }
@@ -33,8 +34,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/qbittorrent", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "config", "id": "config",
@@ -58,13 +59,13 @@
], ],
"volumes": [ "volumes": [
"/services/qbittorrent/config:/config", "/services/qbittorrent/config:/config",
"/services/media/downloads:/downloads" "${access:downloads}:/downloads"
] ]
}, },
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/qbittorrent/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
@@ -75,9 +76,9 @@
"name": "mesh-qbittorrent", "name": "mesh-qbittorrent",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/qbittorrent/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/qbittorrent/password:/run/secrets/password:ro", "${dir:mesh-state}/password:/run/secrets/password:ro",
"/var/lib/mesh/qbittorrent/config.json:/run/config/config.json:ro", "${dir:mesh-state}/config.json:/run/config/config.json:ro",
"/services/qbittorrent/config:/var/lib/qbittorrent/config:ro" "/services/qbittorrent/config:/var/lib/qbittorrent/config:ro"
], ],
"env": { "env": {
+9 -7
View File
@@ -10,7 +10,7 @@
], ],
"consumes": [], "consumes": [],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/radarr/broker" "broker": "${dir:mesh-state}/broker"
}, },
"listens": [ "listens": [
{ {
@@ -23,10 +23,12 @@
], ],
"accesses": [ "accesses": [
{ {
"id": "movies",
"path": "/services/media/movies", "path": "/services/media/movies",
"mode": "read-write" "mode": "read-write"
}, },
{ {
"id": "downloads",
"path": "/services/media/downloads", "path": "/services/media/downloads",
"mode": "read-write" "mode": "read-write"
} }
@@ -35,8 +37,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/radarr", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "config", "id": "config",
@@ -60,8 +62,8 @@
], ],
"volumes": [ "volumes": [
"/services/radarr/config:/config", "/services/radarr/config:/config",
"/services/media/movies:/movies", "${access:movies}:/movies",
"/services/media/downloads:/downloads" "${access:downloads}:/downloads"
] ]
}, },
{ {
@@ -70,7 +72,7 @@
"name": "mesh-radarr", "name": "mesh-radarr",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/radarr/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/services/radarr/config:/var/lib/radarr/config:ro" "/services/radarr/config:/var/lib/radarr/config:ro"
], ],
"env": { "env": {
@@ -91,7 +93,7 @@
} }
}, },
"binds": { "binds": {
"route": "/var/lib/mesh/radarr/route.json" "route": "${dir:mesh-state}/route.json"
}, },
"build": { "build": {
"on": [ "on": [
+13 -13
View File
@@ -9,10 +9,10 @@
"git" "git"
], ],
"binds": { "binds": {
"git": "/var/lib/mesh/records/git.json" "git": "${dir:mesh-state}/git.json"
}, },
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/records/broker" "broker": "${dir:mesh-state}/broker"
}, },
"consumes": [ "consumes": [
"gitea.pull.merged" "gitea.pull.merged"
@@ -28,19 +28,19 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/records", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "checkout", "id": "checkout",
"type": "directory", "type": "directory",
"path": "/var/lib/records", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "config", "id": "config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/records/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
@@ -48,7 +48,7 @@
{ {
"id": "origin", "id": "origin",
"type": "file", "type": "file",
"path": "/var/lib/mesh/records/origin", "path": "${dir:mesh-state}/origin",
"mode": "0600", "mode": "0600",
"content": "${bound:git:scheme}://${bound:git:at}:${bound:git:port}\n" "content": "${bound:git:scheme}://${bound:git:at}:${bound:git:port}\n"
}, },
@@ -58,16 +58,16 @@
"name": "records", "name": "records",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/records/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/records/config.json:/run/config/config.json:ro", "${dir:mesh-state}/config.json:/run/config/config.json:ro",
"/var/lib/mesh/records/origin:/run/config/origin:ro", "${dir:mesh-state}/origin:/run/config/origin:ro",
"/var/lib/records:/var/lib/records" "${dir:checkout}:${dir:checkout}"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECORDS_CONFIG_FILE": "/run/config/config.json", "MESH_RECORDS_CONFIG_FILE": "/run/config/config.json",
"MESH_RECORDS_ORIGIN_FILE": "/run/config/origin", "MESH_RECORDS_ORIGIN_FILE": "/run/config/origin",
"MESH_RECORDS_DIR": "/var/lib/records" "MESH_RECORDS_DIR": "${dir:checkout}"
}, },
"artifact": "runtime", "artifact": "runtime",
"restart-on": [ "restart-on": [
+4 -4
View File
@@ -36,7 +36,7 @@
"secret": "${dir:state}/default.secret" "secret": "${dir:state}/default.secret"
}, },
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/redis/broker" "broker": "${dir:mesh-state}/broker"
}, },
"listens": [ "listens": [
{ {
@@ -51,8 +51,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/redis", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
@@ -110,7 +110,7 @@
"name": "mesh-redis", "name": "mesh-redis",
"network": "redis", "network": "redis",
"volumes": [ "volumes": [
"/var/lib/mesh/redis/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:grants}:/var/lib/redis-module/grants:ro", "${dir:grants}:/var/lib/redis-module/grants:ro",
"${dir:state}/default.secret:/run/secrets/default:ro" "${dir:state}/default.secret:/run/secrets/default:ro"
], ],
+9 -8
View File
@@ -15,10 +15,11 @@
"route": {} "route": {}
}, },
"receives": { "receives": {
"route": "/var/lib/route-adapter/routes/mesh.json" "route": "${dir:routes-dir}/mesh.json"
}, },
"accesses": [ "accesses": [
{ {
"id": "dynamic",
"path": "/services/traefik/dynamic", "path": "/services/traefik/dynamic",
"mode": "read-write" "mode": "read-write"
} }
@@ -27,8 +28,8 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/route-adapter", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "routes-dir", "id": "routes-dir",
@@ -39,7 +40,7 @@
{ {
"id": "config", "id": "config",
"type": "file", "type": "file",
"path": "/var/lib/route-adapter/config.json", "path": "${dir:state}/config.json",
"merge": "json", "merge": "json",
"mode": "0644", "mode": "0644",
"content": "{\n \"dynamic\": \"/services/traefik/dynamic\",\n \"entrypoint\": \"websecure\",\n \"certificate-resolver\": \"le\",\n \"machine\": \"host.docker.internal\"\n}\n" "content": "{\n \"dynamic\": \"/services/traefik/dynamic\",\n \"entrypoint\": \"websecure\",\n \"certificate-resolver\": \"le\",\n \"machine\": \"host.docker.internal\"\n}\n"
@@ -51,12 +52,12 @@
"artifact": "runtime", "artifact": "runtime",
"run-once": true, "run-once": true,
"volumes": [ "volumes": [
"/var/lib/route-adapter/routes/mesh.json:/var/lib/route-adapter/routes/mesh.json:ro", "${dir:routes-dir}/mesh.json:${dir:routes-dir}/mesh.json:ro",
"/var/lib/route-adapter/config.json:/run/config/config.json:ro", "${dir:state}/config.json:/run/config/config.json:ro",
"/services/traefik/dynamic:/services/traefik/dynamic" "${access:dynamic}:/services/traefik/dynamic"
], ],
"env": { "env": {
"MESH_RECEIVES": "/var/lib/route-adapter/routes/mesh.json", "MESH_RECEIVES": "${dir:routes-dir}/mesh.json",
"MESH_ROUTE_ADAPTER_CONFIG": "/run/config/config.json" "MESH_ROUTE_ADAPTER_CONFIG": "/run/config/config.json"
}, },
"args": [ "args": [
+16 -16
View File
@@ -15,15 +15,15 @@
"route": {} "route": {}
}, },
"receives": { "receives": {
"route": "/var/lib/route-proxy/routes/mesh.json" "route": "${dir:routes-dir}/mesh.json"
}, },
"requires": [ "requires": [
"acme-ca", "acme-ca",
"internal-acme-ca" "internal-acme-ca"
], ],
"binds": { "binds": {
"acme-ca": "/var/lib/route-proxy/acme-ca.json", "acme-ca": "${dir:state}/acme-ca.json",
"internal-acme-ca": "/var/lib/route-proxy/internal-acme-ca.json" "internal-acme-ca": "${dir:state}/internal-acme-ca.json"
}, },
"listens": [ "listens": [
{ {
@@ -45,8 +45,8 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/route-proxy", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "routes-dir", "id": "routes-dir",
@@ -69,14 +69,14 @@
{ {
"id": "acme-env", "id": "acme-env",
"type": "file", "type": "file",
"path": "/var/lib/route-proxy/acme.env", "path": "${dir:state}/acme.env",
"mode": "0600", "mode": "0600",
"content": "ACME_DIRECTORY=https://${bound:acme-ca:at}:${bound:acme-ca:port}${bound:acme-ca:path}\nACME_ROOTS=https://${bound:acme-ca:at}:${bound:acme-ca:port}${bound:acme-ca:roots}\nACME_ROOTS_PATH=${bound:acme-ca:roots}\n" "content": "ACME_DIRECTORY=https://${bound:acme-ca:at}:${bound:acme-ca:port}${bound:acme-ca:path}\nACME_ROOTS=https://${bound:acme-ca:at}:${bound:acme-ca:port}${bound:acme-ca:roots}\nACME_ROOTS_PATH=${bound:acme-ca:roots}\n"
}, },
{ {
"id": "internal-acme-env", "id": "internal-acme-env",
"type": "file", "type": "file",
"path": "/var/lib/route-proxy/internal-acme.env", "path": "${dir:state}/internal-acme.env",
"mode": "0600", "mode": "0600",
"content": "INTERNAL_ACME_DIRECTORY=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:path}\nINTERNAL_ACME_ROOTS=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}\nINTERNAL_ACME_ROOTS_PATH=${bound:internal-acme-ca:roots}\n" "content": "INTERNAL_ACME_DIRECTORY=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:path}\nINTERNAL_ACME_ROOTS=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}\nINTERNAL_ACME_ROOTS_PATH=${bound:internal-acme-ca:roots}\n"
}, },
@@ -88,10 +88,10 @@
"run-once": true, "run-once": true,
"network": "host", "network": "host",
"env-file": [ "env-file": [
"/var/lib/route-proxy/acme.env" "${dir:state}/acme.env"
], ],
"volumes": [ "volumes": [
"/var/lib/route-proxy/ca:/ca" "${dir:ca-dir}:/ca"
], ],
"args": [ "args": [
"sh", "sh",
@@ -110,10 +110,10 @@
"run-once": true, "run-once": true,
"network": "host", "network": "host",
"env-file": [ "env-file": [
"/var/lib/route-proxy/internal-acme.env" "${dir:state}/internal-acme.env"
], ],
"volumes": [ "volumes": [
"/var/lib/route-proxy/ca:/ca" "${dir:ca-dir}:/ca"
], ],
"args": [ "args": [
"sh", "sh",
@@ -131,13 +131,13 @@
"artifact": "server", "artifact": "server",
"network": "host", "network": "host",
"env-file": [ "env-file": [
"/var/lib/route-proxy/acme.env", "${dir:state}/acme.env",
"/var/lib/route-proxy/internal-acme.env" "${dir:state}/internal-acme.env"
], ],
"volumes": [ "volumes": [
"/var/lib/route-proxy/routes:/routes:ro", "${dir:routes-dir}:/routes:ro",
"/var/lib/route-proxy/acme:/acme", "${dir:acme-cache}:/acme",
"/var/lib/route-proxy/ca:/ca:ro" "${dir:ca-dir}:/ca:ro"
], ],
"env": { "env": {
"ROUTES": "/routes/mesh.json", "ROUTES": "/routes/mesh.json",
+8 -8
View File
@@ -5,8 +5,8 @@
"container-runtime" "container-runtime"
], ],
"own-secrets": { "own-secrets": {
"secret": "/var/lib/mesh/searxng/secret", "secret": "${dir:mesh-state}/secret",
"broker": "/var/lib/mesh/searxng/broker" "broker": "${dir:mesh-state}/broker"
}, },
"listens": [ "listens": [
{ {
@@ -21,8 +21,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/searxng", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
@@ -85,7 +85,7 @@
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/searxng/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n" "content": "{}\n"
}, },
@@ -95,12 +95,12 @@
"name": "mesh-searxng", "name": "mesh-searxng",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/searxng/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/searxng/config.json:/run/config/config.json:ro" "${dir:mesh-state}/config.json:/run/config/config.json:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_SEARXNG_URL": "http://127.0.0.1:8080", "MESH_SEARXNG_URL": "http://127.0.0.1:${port:8080}",
"MESH_SEARXNG_CONFIG_FILE": "/run/config/config.json" "MESH_SEARXNG_CONFIG_FILE": "/run/config/config.json"
}, },
"restart-on": [ "restart-on": [
+12 -12
View File
@@ -20,13 +20,13 @@
"postgres-database" "postgres-database"
], ],
"binds": { "binds": {
"postgres-database": "/var/lib/showcase/database.json" "postgres-database": "${dir:state}/database.json"
}, },
"secrets": { "secrets": {
"postgres-database": "/var/lib/showcase/database.secret" "postgres-database": "${dir:state}/database.secret"
}, },
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/showcase/broker" "broker": "${dir:mesh-state}/broker"
}, },
"claims": [ "claims": [
{ {
@@ -94,19 +94,19 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/showcase", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/showcase", "mode": "0755",
"mode": "0755" "place": "."
}, },
{ {
"id": "settings", "id": "settings",
"type": "file", "type": "file",
"path": "/var/lib/showcase/showcase.env", "path": "${dir:state}/showcase.env",
"mode": "0600", "mode": "0600",
"content": "SHOWCASE_GREETING=hello\nSHOWCASE_EVERY_SECONDS=30\nSHOWCASE_STATE=/var/lib/showcase\nSHOWCASE_DATABASE=${bound:postgres-database:at}\nSHOWCASE_LISTEN=${port:8080}\n" "content": "SHOWCASE_GREETING=hello\nSHOWCASE_EVERY_SECONDS=30\nSHOWCASE_STATE=/var/lib/showcase\nSHOWCASE_DATABASE=${bound:postgres-database:at}\nSHOWCASE_LISTEN=${port:8080}\n"
}, },
@@ -137,7 +137,7 @@
], ],
"run-once": true, "run-once": true,
"env-file": [ "env-file": [
"/var/lib/showcase/showcase.env" "${dir:state}/showcase.env"
] ]
}, },
{ {
@@ -151,7 +151,7 @@
], ],
"user": "showcase", "user": "showcase",
"env-file": [ "env-file": [
"/var/lib/showcase/showcase.env" "${dir:state}/showcase.env"
], ],
"restart-on": [ "restart-on": [
"settings" "settings"
@@ -168,7 +168,7 @@
], ],
"schedule": "0 3 * * *", "schedule": "0 3 * * *",
"env-file": [ "env-file": [
"/var/lib/showcase/showcase.env" "${dir:state}/showcase.env"
] ]
}, },
{ {
@@ -178,7 +178,7 @@
"artifact": "helper", "artifact": "helper",
"network": "showcase", "network": "showcase",
"volumes": [ "volumes": [
"/var/lib/mesh/showcase/broker:/run/secrets/broker:ro" "${dir:mesh-state}/broker:/run/secrets/broker:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker" "MESH_BROKER_FILE": "/run/secrets/broker"
+11 -8
View File
@@ -10,7 +10,7 @@
], ],
"consumes": [], "consumes": [],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/sonarr/broker" "broker": "${dir:mesh-state}/broker"
}, },
"listens": [ "listens": [
{ {
@@ -23,14 +23,17 @@
], ],
"accesses": [ "accesses": [
{ {
"id": "series",
"path": "/services/media/series", "path": "/services/media/series",
"mode": "read-write" "mode": "read-write"
}, },
{ {
"id": "anime",
"path": "/services/media/anime", "path": "/services/media/anime",
"mode": "read-write" "mode": "read-write"
}, },
{ {
"id": "downloads",
"path": "/services/media/downloads", "path": "/services/media/downloads",
"mode": "read-write" "mode": "read-write"
} }
@@ -39,8 +42,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/sonarr", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "config", "id": "config",
@@ -64,9 +67,9 @@
], ],
"volumes": [ "volumes": [
"/services/sonarr/config:/config", "/services/sonarr/config:/config",
"/services/media/series:/series", "${access:series}:/series",
"/services/media/anime:/anime", "${access:anime}:/anime",
"/services/media/downloads:/downloads" "${access:downloads}:/downloads"
] ]
}, },
{ {
@@ -75,7 +78,7 @@
"name": "mesh-sonarr", "name": "mesh-sonarr",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/sonarr/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/services/sonarr/config:/var/lib/sonarr/config:ro" "/services/sonarr/config:/var/lib/sonarr/config:ro"
], ],
"env": { "env": {
@@ -96,7 +99,7 @@
} }
}, },
"binds": { "binds": {
"route": "/var/lib/mesh/sonarr/route.json" "route": "${dir:mesh-state}/route.json"
}, },
"build": { "build": {
"on": [ "on": [
+10 -10
View File
@@ -34,26 +34,26 @@
} }
], ],
"own-secrets": { "own-secrets": {
"password": "/var/lib/mesh/step-ca/password" "password": "${dir:mesh-state}/password"
}, },
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/step-ca", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "home", "id": "home",
"type": "directory", "type": "directory",
"path": "/var/lib/step-ca",
"mode": "0700", "mode": "0700",
"owner": "1000:1000" "owner": "1000:1000",
"place": "."
}, },
{ {
"id": "config", "id": "config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/step-ca/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0644", "mode": "0644",
"content": "{}", "content": "{}",
"merge": "json" "merge": "json"
@@ -61,7 +61,7 @@
{ {
"id": "init-env", "id": "init-env",
"type": "file", "type": "file",
"path": "/var/lib/mesh/step-ca/init.env", "path": "${dir:mesh-state}/init.env",
"mode": "0600", "mode": "0600",
"content": "DOCKER_STEPCA_INIT_PASSWORD=${secret:password}\nDOCKER_STEPCA_INIT_DNS_NAMES=${machine:at},${machine:name},localhost,127.0.0.1\n" "content": "DOCKER_STEPCA_INIT_PASSWORD=${secret:password}\nDOCKER_STEPCA_INIT_DNS_NAMES=${machine:at},${machine:name},localhost,127.0.0.1\n"
}, },
@@ -72,7 +72,7 @@
"image": "smallstep/step-ca@sha256:a2b17872915c193259b75a5474c398326f41bd199f0842093e52cf4182bc8270", "image": "smallstep/step-ca@sha256:a2b17872915c193259b75a5474c398326f41bd199f0842093e52cf4182bc8270",
"network": "host", "network": "host",
"env-file": [ "env-file": [
"/var/lib/mesh/step-ca/init.env" "${dir:mesh-state}/init.env"
], ],
"env": { "env": {
"DOCKER_STEPCA_INIT_NAME": "Mesh Internal CA", "DOCKER_STEPCA_INIT_NAME": "Mesh Internal CA",
@@ -80,8 +80,8 @@
"DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT": "false" "DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT": "false"
}, },
"volumes": [ "volumes": [
"/var/lib/step-ca:/home/step", "${dir:home}:/home/step",
"/var/lib/mesh/step-ca:/run/mesh:ro" "${dir:mesh-state}:/run/mesh:ro"
], ],
"secrets-in-environment": "the entrypoint honours DOCKER_STEPCA_INIT_PASSWORD_FILE; convertible, awaiting a bed that proves it" "secrets-in-environment": "the entrypoint honours DOCKER_STEPCA_INIT_PASSWORD_FILE; convertible, awaiting a bed that proves it"
} }
+7 -7
View File
@@ -5,7 +5,7 @@
"watch.recorded" "watch.recorded"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/tautulli/broker" "broker": "${dir:mesh-state}/broker"
}, },
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
@@ -23,8 +23,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/tautulli", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "config", "id": "config",
@@ -53,7 +53,7 @@
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/tautulli/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
@@ -64,8 +64,8 @@
"name": "mesh-tautulli", "name": "mesh-tautulli",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/tautulli/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/tautulli/config.json:/run/config/config.json:ro", "${dir:mesh-state}/config.json:/run/config/config.json:ro",
"/services/tautulli/config:/var/lib/tautulli/config:ro" "/services/tautulli/config:/var/lib/tautulli/config:ro"
], ],
"env": { "env": {
@@ -90,7 +90,7 @@
} }
}, },
"binds": { "binds": {
"route": "/var/lib/mesh/tautulli/route.json" "route": "${dir:mesh-state}/route.json"
}, },
"build": { "build": {
"on": [ "on": [
+22 -23
View File
@@ -19,14 +19,14 @@
} }
}, },
"binds": { "binds": {
"postgres-database": "/var/lib/umami/database.json", "postgres-database": "${dir:state}/database.json",
"route": "/var/lib/umami/route.json" "route": "${dir:state}/route.json"
}, },
"secrets": { "secrets": {
"postgres-database": "/var/lib/umami/database.secret", "postgres-database": "${dir:state}/database.secret",
"secret": { "secret": {
"app-secret": "/var/lib/umami/app.secret", "app-secret": "${dir:state}/app.secret",
"admin": "/var/lib/umami/admin.secret" "admin": "${dir:state}/admin.secret"
} }
}, },
"provides": [ "provides": [
@@ -39,13 +39,13 @@
"analytics": {} "analytics": {}
}, },
"receives": { "receives": {
"analytics": "/var/lib/umami/grants/mesh.json" "analytics": "${dir:grants}/mesh.json"
}, },
"grants": { "grants": {
"analytics": "/var/lib/umami/grants" "analytics": "${dir:grants}"
}, },
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/umami/broker" "broker": "${dir:mesh-state}/broker"
}, },
"listens": [ "listens": [
{ {
@@ -53,41 +53,40 @@
"port": 3000, "port": 3000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "one port serves two surfaces \u2014 the dashboard and the collection endpoint that the browsers of every tracked site POST to. Both are reached through the proxy, by name, so the port is how the proxy reaches this module and nothing else (novox/hq ADR 0045). It said \"anywhere\" and gave the reason that the collection endpoint must be public, which is true of the name and not of the port: opened, the machine-side port served the dashboard over plain HTTP to the internet, bypassing every rule the proxy applies by path" "why": "one port serves two surfaces — the dashboard and the collection endpoint that the browsers of every tracked site POST to. Both are reached through the proxy, by name, so the port is how the proxy reaches this module and nothing else (novox/hq ADR 0045). It said \"anywhere\" and gave the reason that the collection endpoint must be public, which is true of the name and not of the port: opened, the machine-side port served the dashboard over plain HTTP to the internet, bypassing every rule the proxy applies by path"
} }
], ],
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/umami", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/umami", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "grants", "id": "grants",
"type": "directory", "type": "directory",
"path": "/var/lib/umami/grants",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "server-env", "id": "server-env",
"type": "file", "type": "file",
"path": "/var/lib/umami/server.env", "path": "${dir:state}/server.env",
"mode": "0600", "mode": "0600",
"content": "DATABASE_URL=postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nDATABASE_TYPE=postgresql\nAPP_SECRET=${secret:app-secret}\n" "content": "DATABASE_URL=postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nDATABASE_TYPE=postgresql\nAPP_SECRET=${secret:app-secret}\n"
}, },
{ {
"id": "provisioner-env", "id": "provisioner-env",
"type": "file", "type": "file",
"path": "/var/lib/umami/provisioner.env", "path": "${dir:state}/provisioner.env",
"mode": "0600", "mode": "0600",
"content": "MESH_PROVISION_UMAMI_URL=http://umami:3000\nGRANTS=/var/lib/umami/grants\n" "content": "MESH_PROVISION_UMAMI_URL=http://umami:3000\nGRANTS=${dir:grants}\n"
}, },
{ {
"id": "net", "id": "net",
@@ -101,7 +100,7 @@
"image": "ghcr.io/umami-software/umami@sha256:85909afc45bdcda1917394594a087421fdbb05610fded0fa9f6fb861abb2f367", "image": "ghcr.io/umami-software/umami@sha256:85909afc45bdcda1917394594a087421fdbb05610fded0fa9f6fb861abb2f367",
"network": "umami", "network": "umami",
"env-file": [ "env-file": [
"/var/lib/umami/server.env" "${dir:state}/server.env"
], ],
"ports": [ "ports": [
"3000" "3000"
@@ -114,17 +113,17 @@
"name": "mesh-umami", "name": "mesh-umami",
"network": "umami", "network": "umami",
"volumes": [ "volumes": [
"/var/lib/mesh/umami/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/umami/grants:/var/lib/umami/grants", "${dir:grants}:${dir:grants}",
"/var/lib/umami/admin.secret:/run/secrets/admin:ro" "${dir:state}/admin.secret:/run/secrets/admin:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "/var/lib/umami/grants/mesh.json", "MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_UMAMI_ADMIN_PASSWORD_FILE": "/run/secrets/admin" "MESH_UMAMI_ADMIN_PASSWORD_FILE": "/run/secrets/admin"
}, },
"env-file": [ "env-file": [
"/var/lib/umami/provisioner.env" "${dir:state}/provisioner.env"
], ],
"artifact": "runtime" "artifact": "runtime"
} }
+7 -7
View File
@@ -73,8 +73,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/unifi", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
@@ -118,7 +118,7 @@
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/unifi/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{\n \"site\": \"default\",\n \"password\": \"${secret:controller}\"\n}\n", "content": "{\n \"site\": \"default\",\n \"password\": \"${secret:controller}\"\n}\n",
"merge": "json" "merge": "json"
@@ -129,8 +129,8 @@
"name": "mesh-unifi", "name": "mesh-unifi",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/unifi/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/unifi/config.json:/run/config/config.json:ro" "${dir:mesh-state}/config.json:/run/config/config.json:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
@@ -158,8 +158,8 @@
"route": "${dir:state}/route.json" "route": "${dir:state}/route.json"
}, },
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/unifi/broker", "broker": "${dir:mesh-state}/broker",
"controller": "/var/lib/mesh/unifi/controller" "controller": "${dir:mesh-state}/controller"
}, },
"build": { "build": {
"on": [ "on": [