Compare commits

..
34 Commits
Author SHA1 Message Date
mesh-admin fd0fa75cc2 Merge pull request 'searxng says it reads its secret key at start, so the mesh may rotate it (hq 180)' (#202) from feat/searxng-says-how-its-secret-is-taken into main 2026-10-01 10:10:05 +00:00
jschoubben 50c08818d6 searxng says it reads its secret key at start, so the mesh may rotate it (hq 180)
The key signs sessions and nothing else holds it; it lands in the settings file the server
restarts on, so a rotation is a new value and a restart.
2026-10-01 12:09:47 +02:00
mesh-admin 1712670610 Merge pull request 'nodered says it reads its API token and admin password at start, so the mesh may rotate them (hq 180)' (#201) from feat/nodered-says-how-its-secrets-are-taken into main 2026-10-01 09:45:32 +00:00
jschoubben 6b0164c2ba nodered says it reads its API token and admin password at start, so the mesh may rotate them (hq 180)
Both land in settings.js and the runtime's config file, and the containers that read them restart
on those files; a rotation is a new value and a restart. The broker credential says nothing yet: its
other party is the bus, and that rotation is the two-party form.
2026-10-01 11:44:29 +02:00
mesh-admin 0966599c8a Merge pull request 'The forge's tools close and read pull requests, read files and branches, and delete a branch' (#200) from feat/the-forges-tools-close-and-read-pull-requests into main 2026-10-01 09:25:51 +00:00
jschoubben 171f8a03f6 The forge's tools close and read pull requests, read files and branches, and delete a branch
Ten tools the console lacked for the actions a review and a merge leave behind: close or reopen a
pull request whose work landed elsewhere, change its title or body, read its files, its diff and its
comments, reopen an issue, read one file at a ref, list branches, delete the branch a closed pull
request leaves. Each is the client's own call; `gitea_api` stays the escape hatch for the rest.
Tested against the fake forge through the compiled tools, the way the console calls them (13/13).
2026-10-01 11:25:34 +02:00
mesh-admin cb48c882a0 Merge pull request 'postgres: its server container is not named after the seat' (#177) from fix/postgres-is-not-named-after-the-seat into main 2026-10-01 09:25:05 +00:00
mesh-admin 3cbd98b14f Merge pull request 'n8n: its media library is an access placed by the assignment' (#199) from fix/n8n-media-access-by-id into main 2026-10-01 00:02:44 +00:00
jschoubben 9fc0d675cd n8n: its media library is an access placed by the assignment
The container mounted /services/media literally — one installation's path
(ADR 0112). The access is now declared by id and mounted as ${access:media};
the assignment says where the library is (ace: /storage/media, hq 153).
2026-10-01 02:02:30 +02:00
mesh-admin 1b0e3841e4 Merge pull request 'n8n: its own image built from source, placed data, and what its workflows use' (#172) from feat/n8n-for-ace into main 2026-09-30 23:59:29 +00:00
jschoubben 5c4364e462 n8n: its own image built from source, placed data, and what its workflows use
The module named /var/lib/n8n, /services/n8n/n8n-data and n8n.novox.be -
paths and a domain no definition may carry (ADR 0112). State and data are
placed directories; the public name is ${bound:route:name} (depends on
mesh-controller #149), for N8N_HOST and WEBHOOK_URL alike.

The endpoint said 5682 while the container publishes 5678. 5682 was one
machine's host port; the endpoint is the software's port and the mesh
assigns the machine's (ADR 0038).

n8n had been run from an image in a registry that no longer exists: the
upstream image plus shadow, a `media` group (2000) with `node` in it, and
a global `uuid`. That recipe is now this module's Dockerfile, built on the
upstream 1.71.3 image named in build.on by digest, with uuid pinned to the
version the running image carries (14.0.1) - Code nodes require() it. The
media group is how the container writes into the shared media library, a
read-write `access` (ADR 0051), mounted where workflows expect it,
/media-library.

The workflows also use a redis (the Redis nodes of the chat workflows) and a
Selenium Chrome (the scraper), which the previous deployment ran beside n8n.
Both are containers on the module's own network, publishing nothing, pinned
to the digests in use; redis keeps its append-only file in a placed
directory.

The basic-auth secret is gone: N8N_BASIC_AUTH_* was removed in n8n 1.0 and
did nothing. The grant's password is a 0400 file owned by `node`, read
through DB_POSTGRESDB_PASSWORD_FILE, so nothing secret is in the
environment. The credentials' encryption key is n8n's own, in the data
directory (config), and moves with it - nothing to mint or accept.

Verified: catalogue tests with MESH_CATALOGUE set; the Dockerfile built
against the pinned base gives n8n 1.71.3, uid 1000 in group 2000, uuid
14.0.1 - the running image's shape. Throwaway containers: an instance on
PostgreSQL 15 with an owner, a workflow and an encrypted credential;
stopped, copied, dumped from the copy, restored (--no-owner --role, the
uuid-ossp extension pre-made by the superuser) into a grant-shaped
database on the postgres module's pgvector image (PG17); the new shape
(password from the file, data dir copied) serves /healthz, the owner logs
in, the workflow is listed, and the credential decrypts with the carried
key. The node user writes into a root:2000 0775 library through the media
group; redis and Selenium resolve by name on the module network and
Selenium reports ready. Test containers and data removed.
2026-10-01 01:52:23 +02:00
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
mesh-admin 12bbcafacf Merge pull request 'gitea: the tools' token carries write:admin; a kept token is re-minted when it lacks a scope' (#192) from feat/gitea-token-write-admin into main 2026-09-30 19:06:05 +00:00
jschoubben d58ed21367 gitea: the tools' token carries write:admin, and a kept token is re-minted when it lacks a scope
The forge's own users are the mesh's to settle — making the builder's login
a site admin so private repos build (hq 229) — and the tools' token had no
write:admin. A token kept from before a scope was added lacks it, so the
client now treats the forge's 403 "required scope" like a 401: the source
re-mints by name with the whole list and retries once. The fake forge in the
tests learns /repos/search, which the client has used since 2026-09-28 and
which had left 9 of the 11 token tests failing on main.
2026-09-30 21:05:52 +02:00
jschoubben 20d8487515 Merge pull request 'website: it listens on the port its container publishes' (#191) from fix/website-listens-its-own-port into main 2026-09-30 19:01:49 +00:00
jschoubben aab40c6e9d website: it listens on the port its container publishes
listens said 4000 while the container publishes 8080; the old assignment's port setting hid it, and
the rename lost the setting, so the proxy dialled a port nothing answered (2026-09-30).
2026-09-30 21:01:47 +02:00
jschoubben ad2aac7bb9 Merge pull request 'website: the container joins the network the module declares' (#190) from fix/website-network into main 2026-09-30 18:56:26 +00:00
jschoubben 202672ee7d website: the container joins the network the module declares
The rename changed the network resource's name and not the container's network, so the container
looked for a network that no longer exists and the site answered 502 (2026-09-30).
2026-09-30 20:56:23 +02:00
mesh-admin ac7a9f2ca8 Merge pull request 'portainer: publish its software ports; the machine side is the mesh's to assign' (#189) from fix/portainer-software-ports into main 2026-09-30 18:54:41 +00:00
jschoubben 80d9e9a7e7 portainer: publish its software ports; the machine side is the mesh's to assign
9090:9000 and 9443:9443 were the predecessor's machine numbers written into the
definition. The manifest now says 9000 and 9443 and the mesh assigns the
machine ports on each node (the portainer slice of #173, which is stale).
2026-09-30 20:54:29 +02:00
jschoubben e0c5acd547 Merge pull request 'No definition names this installation' (#188) from feat/a-definition-names-no-installation into main 2026-09-30 18:49:38 +00:00
jschoubben 3476f1ebec No definition names this installation
keycloak, minio and n8n are told their names from their route bindings; mailu takes its domain, site
name, website and proxy address as settings and its front's name from its route, and the provisioner
reads the domain from the merged config; builder and route-proxy package the controller from the git
seat; the applications built outside the mesh say so per container; matrix says which of the world's
servers it means; the site module is named website, and the why prose no longer names a name (novox/hq
ADR 0155, issues 122 and 134). module check passes over all 77.
2026-09-30 20:49:21 +02:00
jschoubben 76443ec06d postgres: its server container is not named after the seat
The module's server container was called mesh-store and its data directory
/var/lib/mesh-store — the seat's name reused for the module's own resources,
a leftover from the first migration. On a machine whose postgres holds no
seat (ace, as a database provider only) that produced a container called
mesh-store holding nothing of the kind. The container is now named
postgres. The data directory keeps its path: a path change recreates a
running container on an empty directory (hq 126), and novox's store lives
there.

Rolling this out recreates novox's store container once (a restart on its
bind mount, no data moves). The two catalogue-test failures on this branch
(resolver_manifests_test) fail identically on main today.
2026-09-30 14:56:31 +02:00
86 changed files with 2385 additions and 956 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": [
+12 -12
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": [
@@ -69,7 +68,8 @@
"kind": "image", "kind": "image",
"from": "Dockerfile", "from": "Dockerfile",
"context": { "context": {
"repository": "https://git.novox.be/novox/mesh-controller.git", "seat": "git",
"repository": "novox/mesh-controller",
"ref": "main" "ref": "main"
} }
} }
+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",
+12 -9
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": [
{ {
@@ -27,20 +27,20 @@
"port": 35621, "port": 35621,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the de-spiegel site and its /contact endpoint over http; the public name de-spiegel.novox.be is a route grant, and route-proxy reaches it on this published port" "why": "the de-spiegel site and its /contact endpoint over http; its public name is a route grant, and route-proxy reaches it on this published port"
} }
], ],
"resources": [ "resources": [
{ {
"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,12 +56,15 @@
"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"
], ],
"secrets-in-environment": "the application's own code reads SMTP_AUTH_USER/PASS from the environment (de-spiegel server/index.js); converting is that repository's change" "secrets-in-environment": "the application's own code reads SMTP_AUTH_USER/PASS from the environment (de-spiegel server/index.js); converting is that repository's change",
"names-on-purpose": {
"registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)"
}
} }
] ]
} }
+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",
+73
View File
@@ -45,6 +45,14 @@ export interface GiteaPull {
html_url: string; html_url: string;
} }
export interface GiteaComment {
id: number;
user?: string;
body: string;
created_at?: string;
html_url: string;
}
export interface GiteaLabel { export interface GiteaLabel {
id: number; id: number;
name: string; name: string;
@@ -95,6 +103,13 @@ export class GiteaClient {
if (res.status === 401) { if (res.status === 401) {
token = await this.tokens.renew(token); token = await this.tokens.renew(token);
res = await this.send(path, options, token); res = await this.send(path, options, token);
} else if (res.status === 403) {
// A kept token minted before a scope was added lacks it. The forge says so; the source
// re-mints with the whole list and the call is retried once. Any other 403 stays a 403.
const text = await res.text();
if (!MintedToken.lacksScope(res.status, text)) throw new Error(`Gitea API ${path}: 403 ${text}`);
token = await this.tokens.renew(token);
res = await this.send(path, options, token);
} }
if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`); if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`);
if (res.status === 204) return null as T; if (res.status === 204) return null as T;
@@ -250,6 +265,64 @@ export class GiteaClient {
); );
} }
/** Close or reopen a pull request without merging it. A pull request is an issue to the forge's
* state machine, and the pulls endpoint takes the same `state`. */
async setPullState(owner: string, repo: string, index: number, state: "open" | "closed"): Promise<GiteaPull> {
return GiteaClient.mapPull(
await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`, { method: "PATCH", body: JSON.stringify({ state }) }),
);
}
/** Change a pull request's title or body; a field left undefined is left alone. */
async updatePullRequest(owner: string, repo: string, index: number, data: { title?: string; body?: string }): Promise<GiteaPull> {
return GiteaClient.mapPull(
await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`, { method: "PATCH", body: JSON.stringify(data) }),
);
}
/** The unified diff of a pull request, as text. */
async pullDiff(owner: string, repo: string, index: number): Promise<string> {
return this.requestText(`/repos/${owner}/${repo}/pulls/${index}.diff`);
}
/** Every comment on an issue or pull request, oldest first. */
async listComments(owner: string, repo: string, index: number): Promise<GiteaComment[]> {
const raw = await this.request<any[]>(`/repos/${owner}/${repo}/issues/${index}/comments`);
return (raw ?? []).map((c) => ({
id: Number(c?.id ?? 0),
user: c?.user?.login,
body: String(c?.body ?? ""),
created_at: c?.created_at,
html_url: String(c?.html_url ?? ""),
}));
}
/** One file's contents at a ref (default the repository's default branch), decoded. */
async getFile(owner: string, repo: string, path: string, ref?: string): Promise<{ path: string; ref?: string; sha: string; size: number; content: string }> {
const qs = ref ? `?ref=${encodeURIComponent(ref)}` : "";
const f = await this.request<any>(`/repos/${owner}/${repo}/contents/${path.split("/").map(encodeURIComponent).join("/")}${qs}`);
if (!f || f.type !== "file") throw new Error(`Gitea API: ${path} is not a file`);
const content = f.encoding === "base64" ? Buffer.from(String(f.content ?? ""), "base64").toString("utf8") : String(f.content ?? "");
return { path, ref, sha: String(f.sha ?? ""), size: Number(f.size ?? content.length), content };
}
async listBranches(owner: string, repo: string): Promise<{ name: string; commit: string; protected: boolean }[]> {
const raw = await this.request<any[]>(`/repos/${owner}/${repo}/branches?limit=100`);
return (raw ?? []).map((b) => ({ name: String(b?.name ?? ""), commit: String(b?.commit?.id ?? ""), protected: Boolean(b?.protected) }));
}
async deleteBranch(owner: string, repo: string, branch: string): Promise<void> {
await this.request(`/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}`, { method: "DELETE" });
}
/** A request whose answer is text, not JSON — a diff. Same token handling as request(). */
private async requestText(path: string): Promise<string> {
const token = await this.tokens.current();
const res = await this.send(path, { headers: { Accept: "text/plain" } }, token);
if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`);
return res.text();
}
async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false): Promise<void> { async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false): Promise<void> {
await this.request(`/repos/${owner}/${repo}/pulls/${index}/merge`, { await this.request(`/repos/${owner}/${repo}/pulls/${index}/merge`, {
method: "POST", method: "POST",
+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",
+140 -2
View File
@@ -29,7 +29,11 @@ interface Forge {
mints: number; mints: number;
lastScopes: string[] | null; lastScopes: string[] | null;
tokens: Map<string, string>; tokens: Map<string, string>;
scopesOf: Map<string, string[]>;
admins: Map<string, string>; admins: Map<string, string>;
pullState: string;
pullTitle: string;
branchDeleted: boolean;
close(): Promise<void>; close(): Promise<void>;
} }
@@ -40,6 +44,9 @@ function fakeForge(): Promise<Forge> {
tokens: new Map<string, string>(), // name -> value tokens: new Map<string, string>(), // name -> value
scopesOf: new Map<string, string[]>(), // value -> scopes, so a route can enforce them like gitea does scopesOf: new Map<string, string[]>(), // value -> scopes, so a route can enforce them like gitea does
admins: new Map([[ADMIN, PASSWORD]]), admins: new Map([[ADMIN, PASSWORD]]),
pullState: "open",
pullTitle: "The console shipped",
branchDeleted: false,
}; };
// write:X implies read:X — gitea's own rule (models/auth/access_token_scope.go). // write:X implies read:X — gitea's own rule (models/auth/access_token_scope.go).
const covers = (scopes: string[], required: string): boolean => const covers = (scopes: string[], required: string): boolean =>
@@ -85,6 +92,51 @@ function fakeForge(): Promise<Forge> {
} }
return json(res, 405, { message: "method not allowed" }); return json(res, 405, { message: "method not allowed" });
} }
const tokenOf = (): string => {
const h = req.headers.authorization ?? "";
return h.startsWith("token ") ? h.slice(6) : "";
};
const pull = url.pathname.match(/^\/api\/v1\/repos\/novox\/hq\/pulls\/(\d+)(\.diff)?$/);
if (pull) {
if (![...forge.tokens.values()].includes(tokenOf())) return json(res, 401, { message: "token is required" });
if (pull[2]) {
res.writeHead(200, { "Content-Type": "text/plain" });
return res.end("diff --git a/x b/x\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new\n");
}
if (req.method === "PATCH") {
const patch = await body(req);
forge.pullState = patch?.state ?? forge.pullState;
forge.pullTitle = patch?.title ?? forge.pullTitle;
}
return json(res, 200, { number: Number(pull[1]), title: forge.pullTitle, state: forge.pullState, merged: false,
user: { login: "mesh-admin" }, head: { ref: "feat/x" }, base: { ref: "main" }, html_url: "http://fake/novox/hq/pulls/" + pull[1] });
}
if (url.pathname === "/api/v1/repos/novox/hq/issues/223/comments") {
return json(res, 200, [{ id: 1, user: { login: "jochen" }, body: "landed elsewhere", created_at: "2026-10-01T00:00:00Z", html_url: "http://fake/c/1" }]);
}
if (url.pathname === "/api/v1/repos/novox/hq/contents/README.md") {
return json(res, 200, { type: "file", encoding: "base64", sha: "abc", size: 5, content: Buffer.from("hello").toString("base64") });
}
if (url.pathname === "/api/v1/repos/novox/hq/branches") {
return json(res, 200, [{ name: "main", protected: true, commit: { id: "aaaa" } }, { name: "feat/x", protected: false, commit: { id: "bbbb" } }]);
}
if (url.pathname === "/api/v1/repos/novox/hq/branches/feat%2Fx" || url.pathname === "/api/v1/repos/novox/hq/branches/feat/x") {
if (req.method === "DELETE") { forge.branchDeleted = true; return json(res, 204, null); }
}
if (url.pathname === "/api/v1/repos/search") {
// The client lists through the search endpoint since 2026-09-28 (the forge's whole view);
// it sits under `repository`, which write:repository covers.
const h = req.headers.authorization ?? "";
const value = h.startsWith("token ") ? h.slice(6) : "";
if (![...forge.tokens.values()].includes(value)) return json(res, 401, { message: "token is required" });
if (!covers(forge.scopesOf.get(value) ?? [], "read:repository")) {
return json(res, 403, { message: `token does not have at least one of required scope(s), required=[read:repository]` });
}
return json(res, 200, {
ok: true,
data: [{ full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" }],
});
}
if (url.pathname === "/api/v1/user/repos") { if (url.pathname === "/api/v1/user/repos") {
const h = req.headers.authorization ?? ""; const h = req.headers.authorization ?? "";
const value = h.startsWith("token ") ? h.slice(6) : ""; const value = h.startsWith("token ") ? h.slice(6) : "";
@@ -101,6 +153,21 @@ function fakeForge(): Promise<Forge> {
{ full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" }, { full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" },
]); ]);
} }
const adminUser = url.pathname.match(/^\/api\/v1\/admin\/users\/([^/]+)$/);
if (adminUser && req.method === "PATCH") {
const h = req.headers.authorization ?? "";
const value = h.startsWith("token ") ? h.slice(6) : "";
if (![...forge.tokens.values()].includes(value)) return json(res, 401, { message: "token is required" });
if (!covers(forge.scopesOf.get(value) ?? [], "write:admin")) {
return json(res, 403, {
message: `token does not have at least one of required scope(s), required=[write:admin]`,
});
}
const login = decodeURIComponent(adminUser[1]);
if (login === "untouchable") return json(res, 403, { message: "user untouchable may not be edited" });
const patch = await body(req);
return json(res, 200, { login, is_admin: patch?.admin === true });
}
return json(res, 404, { message: "no such route in the fake" }); return json(res, 404, { message: "no such route in the fake" });
}); });
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -110,7 +177,11 @@ function fakeForge(): Promise<Forge> {
url: `http://127.0.0.1:${port}`, url: `http://127.0.0.1:${port}`,
get mints() { return forge.mints; }, get mints() { return forge.mints; },
get lastScopes() { return forge.lastScopes; }, get lastScopes() { return forge.lastScopes; },
get pullState() { return forge.pullState; },
get pullTitle() { return forge.pullTitle; },
get branchDeleted() { return forge.branchDeleted; },
tokens: forge.tokens, tokens: forge.tokens,
scopesOf: forge.scopesOf,
admins: forge.admins, admins: forge.admins,
close: () => new Promise((r) => server.close(() => r())), close: () => new Promise((r) => server.close(() => r())),
}); });
@@ -152,14 +223,14 @@ function minted(env: NodeJS.ProcessEnv, logs: string[]): GiteaClient {
const forge = await fakeForge(); const forge = await fakeForge();
after(() => forge.close()); after(() => forge.close());
test("first start: mints with the admin account, keeps the token at 0600, asks for two scopes only", async () => { test("first start: mints with the admin account, keeps the token at 0600, asks for the tools' scopes only", async () => {
const { env, file, logs } = await delivered(forge); const { env, file, logs } = await delivered(forge);
const repos = await minted(env, logs).listRepos(); const repos = await minted(env, logs).listRepos();
assert.equal(repos[0]?.full_name, "novox/hq"); assert.equal(repos[0]?.full_name, "novox/hq");
assert.equal(forge.mints, 1); assert.equal(forge.mints, 1);
assert.deepEqual(forge.lastScopes, ["write:repository", "write:issue", "read:user"]); assert.deepEqual(forge.lastScopes, ["write:repository", "write:issue", "read:user", "write:admin"]);
assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]); assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]);
const token = forge.tokens.get("mesh-tools")!; const token = forge.tokens.get("mesh-tools")!;
assert.equal(await readFile(file, "utf8"), token + "\n"); assert.equal(await readFile(file, "utf8"), token + "\n");
@@ -197,6 +268,34 @@ test("the forge rejects the kept token (its data was restored): minted afresh, o
assert.ok(logs.some((l) => l.startsWith("the forge rejected the kept token")), logs.join("\n")); assert.ok(logs.some((l) => l.startsWith("the forge rejected the kept token")), logs.join("\n"));
}); });
test("a kept token from before write:admin: the forge refuses the admin route for the scope, the token is re-minted with the whole list, and the call goes through", async () => {
const { env, file, logs } = await delivered(forge);
const client = minted(env, logs);
await client.listRepos();
const before = forge.mints;
const old = forge.tokens.get("mesh-tools")!;
forge.scopesOf.set(old, ["write:repository", "write:issue", "read:user"]); // minted by the previous build
const user = await client.api<{ login: string; is_admin: boolean }>("/admin/users/mesh_novox_builder", {
method: "PATCH",
body: JSON.stringify({ admin: true }),
});
assert.equal(user.is_admin, true);
assert.equal(forge.mints, before + 1);
assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]);
assert.notEqual(forge.tokens.get("mesh-tools"), old);
assert.equal(await readFile(file, "utf8"), forge.tokens.get("mesh-tools") + "\n");
assert.ok(logs.some((l) => l.startsWith("the forge rejected the kept token")), logs.join("\n"));
// A 403 that is not about scopes is the forge's answer, not a reason to mint.
const again = forge.mints;
await assert.rejects(
client.api("/admin/users/untouchable", { method: "PATCH", body: JSON.stringify({ admin: true }) }),
/403 .*untouchable/,
);
assert.equal(forge.mints, again);
});
test("the kept file is gone but the forge still holds a token by that name: replaced, not refused", async () => { test("the kept file is gone but the forge still holds a token by that name: replaced, not refused", async () => {
const { env, file, logs } = await delivered(forge); const { env, file, logs } = await delivered(forge);
await minted(env, logs).listRepos(); await minted(env, logs).listRepos();
@@ -301,6 +400,16 @@ test("the tools register once there is a way to a token, and the first call mint
"gitea_list_repos", "gitea_create_repo", "gitea_delete_repo", "gitea_list_repos", "gitea_create_repo", "gitea_delete_repo",
"gitea_list_issues", "gitea_get_issue", "gitea_create_issue", "gitea_close_issue", "gitea_add_comment", "gitea_list_issues", "gitea_get_issue", "gitea_create_issue", "gitea_close_issue", "gitea_add_comment",
"gitea_list_pull_requests", "gitea_get_pull_request", "gitea_create_pull_request", "gitea_merge_pull_request", "gitea_list_pull_requests", "gitea_get_pull_request", "gitea_create_pull_request", "gitea_merge_pull_request",
"gitea_close_pull_request",
"gitea_reopen_pull_request",
"gitea_update_pull_request",
"gitea_pull_request_files",
"gitea_pull_request_diff",
"gitea_list_comments",
"gitea_reopen_issue",
"gitea_get_file",
"gitea_list_branches",
"gitea_delete_branch",
"gitea_list_labels", "gitea_create_label", "gitea_list_labels", "gitea_create_label",
"gitea_api", "gitea_api",
], ],
@@ -311,3 +420,32 @@ test("the tools register once there is a way to a token, and the first call mint
assert.equal(result.repos.length, 1); assert.equal(result.repos.length, 1);
assert.equal(forge.mints, before + 1); assert.equal(forge.mints, before + 1);
}); });
// The forge's tools reach every action a review needs without a checkout and without the API
// escape hatch: close a pull request whose work landed elsewhere, read its diff, its comments, a
// file, the branches, and delete the branch left behind. Against the fake forge, through the
// compiled tools, the way the console calls them.
test("a pull request can be closed, read and cleaned up through the tools", async () => {
const { env } = await delivered(forge);
const tools = collectTools(env).find((c) => c.module === "gitea")!.tools;
const tool = (name: string) => tools.find((t) => t.name === name)!;
for (const name of ["gitea_close_pull_request", "gitea_reopen_pull_request", "gitea_update_pull_request", "gitea_pull_request_files",
"gitea_pull_request_diff", "gitea_list_comments", "gitea_reopen_issue", "gitea_get_file", "gitea_list_branches", "gitea_delete_branch"]) {
assert.ok(tool(name), `${name} is not a tool`);
}
const closed = (await tool("gitea_close_pull_request").run({ owner: "novox", repo: "hq", number: 223 })) as { pull: { state: string } };
assert.equal(closed.pull.state, "closed");
assert.equal(forge.pullState, "closed");
const renamed = (await tool("gitea_update_pull_request").run({ owner: "novox", repo: "hq", number: 223, title: "Superseded" })) as { pull: { title: string } };
assert.equal(renamed.pull.title, "Superseded");
const diff = (await tool("gitea_pull_request_diff").run({ owner: "novox", repo: "hq", number: 223 })) as { diff: string };
assert.match(diff.diff, /^diff --git/);
const comments = (await tool("gitea_list_comments").run({ owner: "novox", repo: "hq", number: 223 })) as { comments: { body: string }[] };
assert.equal(comments.comments[0].body, "landed elsewhere");
const file = (await tool("gitea_get_file").run({ owner: "novox", repo: "hq", path: "README.md" })) as { file: { content: string } };
assert.equal(file.file.content, "hello");
const branches = (await tool("gitea_list_branches").run({ owner: "novox", repo: "hq" })) as { branches: { name: string }[] };
assert.deepEqual(branches.branches.map((b) => b.name), ["main", "feat/x"]);
await tool("gitea_delete_branch").run({ owner: "novox", repo: "hq", branch: "feat/x" });
assert.equal(forge.branchDeleted, true);
});
+14 -3
View File
@@ -34,15 +34,21 @@ export const TOKEN_NAME = "mesh-tools";
* It sits under the `user` category despite listing repositories, not `repository` * It sits under the `user` category despite listing repositories, not `repository`
* — confirmed against the running forge (1.27.3), which answered * — confirmed against the running forge (1.27.3), which answered
* `required=[read:user]` to a token carrying only the other two. * `required=[read:user]` to a token carrying only the other two.
* Nothing under /admin, /orgs or write:user — the escape-hatch tool reaches only what these three cover. * write:admin — /admin/users: the forge's own users are the mesh's to settle, such as making
* the builder's login a site admin so every repository the mesh may build is
* clonable (novox/hq 229). Nothing under /orgs or write:user.
*
* A token kept from before a scope was added lacks it: the forge answers such a call with
* `403 token does not have at least one of required scope(s)`, and the client treats that like a
* 401 — the source re-mints by name, with the whole list, and the call is retried once.
*/ */
export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue", "read:user"]; export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue", "read:user", "write:admin"];
/** Where a client's token comes from, and what to do when the forge says it is wrong. */ /** Where a client's token comes from, and what to do when the forge says it is wrong. */
export interface TokenSource { export interface TokenSource {
/** The token to authenticate with now; minted, read or configured. */ /** The token to authenticate with now; minted, read or configured. */
current(): Promise<string>; current(): Promise<string>;
/** The forge answered 401 to `rejected`. A fresh token, or a plain error when there is nothing to renew with. */ /** The forge answered 401 to `rejected`, or 403 for a scope it lacks. A fresh token, or a plain error when there is nothing to renew with. */
renew(rejected: string): Promise<string>; renew(rejected: string): Promise<string>;
} }
@@ -170,6 +176,11 @@ export class MintedToken implements TokenSource {
return this.mint("the forge rejected the kept token — minting a fresh one"); return this.mint("the forge rejected the kept token — minting a fresh one");
} }
/** What the forge's scoped tokens say when a kept token predates a scope the tools now need. */
static lacksScope(status: number, body: string): boolean {
return status === 403 && /required scope/i.test(body);
}
/** One mint at a time: concurrent first calls share it, rather than each minting its own. */ /** One mint at a time: concurrent first calls share it, rather than each minting its own. */
private mint(why: string): Promise<string> { private mint(why: string): Promise<string> {
if (this.inflight === null) { if (this.inflight === null) {
+125
View File
@@ -254,6 +254,131 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
}, },
}, },
{
name: "gitea_close_pull_request",
description: "Close a pull request without merging it — one whose work landed elsewhere, or was abandoned.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
number: { type: "number", description: "the PR number" },
},
run: async (args) => ({
pull: await gitea.setPullState(String(args.owner), String(args.repo), Number(args.number), "closed"),
}),
},
{
name: "gitea_reopen_pull_request",
description: "Reopen a closed, unmerged pull request.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
number: { type: "number", description: "the PR number" },
},
run: async (args) => ({
pull: await gitea.setPullState(String(args.owner), String(args.repo), Number(args.number), "open"),
}),
},
{
name: "gitea_update_pull_request",
description: "Change a pull request's title or body; a field not given is left as it is.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
number: { type: "number", description: "the PR number" },
title: { type: "string", description: "the new title (optional)" },
body: { type: "string", description: "the new body, markdown (optional)" },
},
run: async (args) => ({
pull: await gitea.updatePullRequest(String(args.owner), String(args.repo), Number(args.number), {
title: args.title === undefined ? undefined : String(args.title),
body: args.body === undefined ? undefined : String(args.body),
}),
}),
},
{
name: "gitea_pull_request_files",
description: "The files a pull request changes, as paths from the repository's root (up to 100; says when there are more).",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
number: { type: "number", description: "the PR number" },
},
run: async (args) => gitea.listPullFiles(String(args.owner), String(args.repo), Number(args.number)),
},
{
name: "gitea_pull_request_diff",
description: "A pull request's unified diff, as text — for reviewing it without a checkout.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
number: { type: "number", description: "the PR number" },
},
run: async (args) => ({
diff: await gitea.pullDiff(String(args.owner), String(args.repo), Number(args.number)),
}),
},
{
name: "gitea_list_comments",
description: "Every comment on an issue or pull request, oldest first.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
number: { type: "number", description: "the issue or PR number" },
},
run: async (args) => ({
comments: await gitea.listComments(String(args.owner), String(args.repo), Number(args.number)),
}),
},
{
name: "gitea_reopen_issue",
description: "Reopen a closed issue.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
number: { type: "number", description: "the issue number" },
},
run: async (args) => ({
issue: await gitea.setIssueState(String(args.owner), String(args.repo), Number(args.number), "open"),
}),
},
// ---- Contents and branches ----
{
name: "gitea_get_file",
description: "One file's contents from a repository, decoded, at a branch, tag or commit (default the repository's default branch).",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
path: { type: "string", description: "the file's path from the repository's root" },
ref: { type: "string", description: "branch, tag or commit (optional)" },
},
run: async (args) => ({
file: await gitea.getFile(String(args.owner), String(args.repo), String(args.path), args.ref ? String(args.ref) : undefined),
}),
},
{
name: "gitea_list_branches",
description: "Every branch of a repository with the commit it points at.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
},
run: async (args) => ({ branches: await gitea.listBranches(String(args.owner), String(args.repo)) }),
},
{
name: "gitea_delete_branch",
description: "Delete a branch — a feature branch whose pull request was closed rather than merged. Refused by the forge for a protected branch.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
branch: { type: "string", description: "the branch name" },
},
run: async (args) => {
await gitea.deleteBranch(String(args.owner), String(args.repo), String(args.branch));
return { deleted: true, branch: String(args.branch) };
},
},
// ---- Labels ---- // ---- Labels ----
{ {
name: "gitea_list_labels", name: "gitea_list_labels",
+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"
], ],
+19 -13
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"
}, },
@@ -87,7 +87,10 @@
}, },
"ports": [ "ports": [
"80" "80"
] ],
"names-on-purpose": {
"registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)"
}
}, },
{ {
"id": "api", "id": "api",
@@ -100,12 +103,15 @@
"GID": "2201" "GID": "2201"
}, },
"env-file": [ "env-file": [
"/var/lib/invoicing/api.env" "${dir:state}/api.env"
], ],
"ports": [ "ports": [
"9000" "9000"
], ],
"secrets-in-environment": "the application's own code reads MONGO_URL and MINIO_SECRET from the environment (invoicing-app server/src/config.js); converting is that repository's change" "secrets-in-environment": "the application's own code reads MONGO_URL and MINIO_SECRET from the environment (invoicing-app server/src/config.js); converting is that repository's change",
"names-on-purpose": {
"registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)"
}
} }
] ]
} }
-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"
} }
+17
View File
@@ -0,0 +1,17 @@
# mailu
Mail — Mailu, with its provisioner (the `smtp` provision) and tools, on the tool runtime.
## Settings
A definition names no mesh (novox/hq ADR 0112, ADR 0155), so the values that are this
installation's are settings on the assignment, `settings set mailu <file>`:
```json
{"domain": "…", "sitename": "…", "website": "https://…", "proxy-address": "…"}
```
`domain` is the mail domain (also the provisioner's, for a consumer's address); `sitename` and
`website` are shown by the web front; `proxy-address` is what `REAL_IP_FROM` trusts a real-IP
header from — the address the proxy forwards with. The front's own hostname is the name of its
`web` route, told to it by the mesh.
+10 -12
View File
@@ -127,25 +127,25 @@
"port": 7443, "port": 7443,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the web front over its own TLS (admin, webmail, API); the public name mail.novox.be is a route grant reaching it here" "why": "the web front over its own TLS (admin, webmail, API); its public name is a route grant reaching it here"
}, },
{ {
"name": "autoconfig", "name": "autoconfig",
"port": 4243, "port": 4243,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "automx: mail client autoconfiguration; autoconfig/autodiscover/automx.novox.be are route grants reaching it here" "why": "automx: mail client autoconfiguration; the autoconfig, autodiscover and automx names are route grants reaching it here"
} }
], ],
"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",
@@ -168,7 +168,7 @@
"type": "file", "type": "file",
"path": "${dir:state}/mailu.env", "path": "${dir:state}/mailu.env",
"mode": "0644", "mode": "0644",
"content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=142.132.152.141\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n" "content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=${setting:domain}\nHOSTNAMES=${bound:route:name-web}\nPOSTMASTER=admin\nSITENAME=${setting:sitename}\nWEBSITE=${setting:website}\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=${setting:domain}\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=${bound:route:name-web}\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=${bound:route:name-web}\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=${setting:domain}\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=${setting:proxy-address}\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n"
}, },
{ {
"id": "secret-env", "id": "secret-env",
@@ -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": {
@@ -490,7 +490,6 @@
"MESH_MAILU_API_KEY_FILE": "/run/secrets/api-token", "MESH_MAILU_API_KEY_FILE": "/run/secrets/api-token",
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap", "MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
"MESH_MAILU_CONFIG_FILE": "/run/config/config.json", "MESH_MAILU_CONFIG_FILE": "/run/config/config.json",
"MESH_MAILU_DOMAIN": "novox.be",
"MESH_RECEIVES": "${dir:grants}/mesh.json" "MESH_RECEIVES": "${dir:grants}/mesh.json"
}, },
"restart-on": [ "restart-on": [
@@ -554,8 +553,7 @@
"serves": { "serves": {
"smtp": { "smtp": {
"port": 587, "port": 587,
"domain": "novox.be", "domain": "${setting:domain}"
"name": "mail.novox.be"
} }
}, },
"receives": { "receives": {
+18 -3
View File
@@ -13,17 +13,32 @@
// it to both ends; mailu sets exactly that password every run — so a rotation takes — and seals // it to both ends; mailu sets exactly that password every run — so a rotation takes — and seals
// nothing: the consumer already has its copy through the mesh's own channel. // nothing: the consumer already has its copy through the mesh's own channel.
import { readFileSync } from "node:fs";
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { MailuClient } from "../client.js"; import { MailuClient } from "../client.js";
const mailu = MailuClient.fromEnv(); const mailu = MailuClient.fromEnv();
// The mail server's own domain. From the environment the manifest composes, because the client's // The mail server's own domain: the operator's value, from the settings the mesh merges into this
// config file carries the admin API's coordinates, not the mail domain. // module's config file (`settings set mailu` with {"domain": …}; novox/hq ADR 0112, ADR 0155). A
// definition names no mesh, so it is never a literal in the manifest — and it used to be, as
// MESH_MAILU_DOMAIN, which is still read for a mesh that has not re-registered the manifest.
function domain(): string { function domain(): string {
const file = process.env.MESH_MAILU_CONFIG_FILE;
if (file) {
try {
const config = JSON.parse(readFileSync(file, "utf8")) as { domain?: unknown };
if (typeof config.domain === "string" && config.domain.trim() !== "") return config.domain.trim();
} catch {
// Unreadable or not JSON: fall through to the environment, and the error below names both.
}
}
const named = (process.env.MESH_MAILU_DOMAIN ?? "").trim(); const named = (process.env.MESH_MAILU_DOMAIN ?? "").trim();
if (named === "") { if (named === "") {
throw new Error("MESH_MAILU_DOMAIN is not set, so a consumer's address cannot be composed"); throw new Error(
"no mail domain is set, so a consumer's address cannot be composed — `settings set mailu <file>` " +
'with {"domain": "<the mail domain>"}',
);
} }
return named; return named;
} }
+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",
+17 -2
View File
@@ -55,7 +55,10 @@
"type": "file", "type": "file",
"path": "${dir:state}/conduit.toml", "path": "${dir:state}/conduit.toml",
"mode": "0644", "mode": "0644",
"content": "# Written by the mesh (modules/matrix). Conduit reads this file (CONDUIT_CONFIG); nothing comes\n# from the environment. server_name is the homeserver's permanent identity: every user id, room id\n# and signature in the database carries it, so it is the name this module is served under\n# (${bound:route:name-homeserver}) and never changes once a database exists.\n[global]\nserver_name = \"${bound:route:name-homeserver}\"\ndatabase_backend = \"rocksdb\"\ndatabase_path = \"/var/lib/matrix-conduit/\"\naddress = \"0.0.0.0\"\nport = 6167\nmax_request_size = 20000000\nallow_registration = false\nallow_federation = true\nallow_check_for_updates = true\ntrusted_servers = [\"matrix.org\"]\n" "content": "# Written by the mesh (modules/matrix). Conduit reads this file (CONDUIT_CONFIG); nothing comes\n# from the environment. server_name is the homeserver's permanent identity: every user id, room id\n# and signature in the database carries it, so it is the name this module is served under\n# (${bound:route:name-homeserver}) and never changes once a database exists.\n[global]\nserver_name = \"${bound:route:name-homeserver}\"\ndatabase_backend = \"rocksdb\"\ndatabase_path = \"/var/lib/matrix-conduit/\"\naddress = \"0.0.0.0\"\nport = 6167\nmax_request_size = 20000000\nallow_registration = false\nallow_federation = true\nallow_check_for_updates = true\ntrusted_servers = [\"matrix.org\"]\n",
"names-on-purpose": {
"matrix.org": "the federation's public key server, trusted by default; the world's, not this mesh's"
}
}, },
{ {
"id": "element-conf", "id": "element-conf",
@@ -63,7 +66,19 @@
"path": "${dir:state}/element.json", "path": "${dir:state}/element.json",
"mode": "0644", "mode": "0644",
"merge": "json", "merge": "json",
"content": "{\n \"default_server_name\": \"${bound:route:name-homeserver}\",\n \"default_server_config\": {\n \"m.homeserver\": {\n \"base_url\": \"https://${bound:route:name-homeserver}\"\n },\n \"m.identity_server\": {\n \"base_url\": \"https://vector.im\"\n }\n },\n \"brand\": \"Element\",\n \"integrations_ui_url\": \"https://scalar.vector.im/\",\n \"integrations_rest_url\": \"https://scalar.vector.im/api\",\n \"integrations_widgets_urls\": [\n \"https://scalar.vector.im/_matrix/integrations/v1\",\n \"https://scalar.vector.im/api\",\n \"https://scalar-staging.vector.im/_matrix/integrations/v1\",\n \"https://scalar-staging.vector.im/api\",\n \"https://scalar-staging.riot.im/scalar/api\"\n ],\n \"bug_report_endpoint_url\": \"https://element.io/bugreports/submit\",\n \"uisi_autorageshake_app\": \"element-auto-uisi\",\n \"show_labs_settings\": true,\n \"room_directory\": {\n \"servers\": [\n \"${bound:route:name-homeserver}\",\n \"matrix.org\",\n \"gitter.im\",\n \"libera.chat\"\n ]\n },\n \"enable_presence_by_hs_url\": {\n \"https://matrix.org\": false,\n \"https://matrix-client.matrix.org\": false\n },\n \"terms_and_conditions_links\": [\n {\n \"url\": \"https://element.io/privacy\",\n \"text\": \"Privacy Policy\"\n },\n {\n \"url\": \"https://element.io/cookie-policy\",\n \"text\": \"Cookie Policy\"\n }\n ],\n \"features\": {\n \"feature_video_rooms\": true,\n \"feature_rust_crypto\": true\n },\n \"element_call\": {\n \"url\": \"https://call.element.dev\"\n }\n}\n" "content": "{\n \"default_server_name\": \"${bound:route:name-homeserver}\",\n \"default_server_config\": {\n \"m.homeserver\": {\n \"base_url\": \"https://${bound:route:name-homeserver}\"\n },\n \"m.identity_server\": {\n \"base_url\": \"https://vector.im\"\n }\n },\n \"brand\": \"Element\",\n \"integrations_ui_url\": \"https://scalar.vector.im/\",\n \"integrations_rest_url\": \"https://scalar.vector.im/api\",\n \"integrations_widgets_urls\": [\n \"https://scalar.vector.im/_matrix/integrations/v1\",\n \"https://scalar.vector.im/api\",\n \"https://scalar-staging.vector.im/_matrix/integrations/v1\",\n \"https://scalar-staging.vector.im/api\",\n \"https://scalar-staging.riot.im/scalar/api\"\n ],\n \"bug_report_endpoint_url\": \"https://element.io/bugreports/submit\",\n \"uisi_autorageshake_app\": \"element-auto-uisi\",\n \"show_labs_settings\": true,\n \"room_directory\": {\n \"servers\": [\n \"${bound:route:name-homeserver}\",\n \"matrix.org\",\n \"gitter.im\",\n \"libera.chat\"\n ]\n },\n \"enable_presence_by_hs_url\": {\n \"https://matrix.org\": false,\n \"https://matrix-client.matrix.org\": false\n },\n \"terms_and_conditions_links\": [\n {\n \"url\": \"https://element.io/privacy\",\n \"text\": \"Privacy Policy\"\n },\n {\n \"url\": \"https://element.io/cookie-policy\",\n \"text\": \"Cookie Policy\"\n }\n ],\n \"features\": {\n \"feature_video_rooms\": true,\n \"feature_rust_crypto\": true\n },\n \"element_call\": {\n \"url\": \"https://call.element.dev\"\n }\n}\n",
"names-on-purpose": {
"matrix.org": "the public room directory and the federation's largest homeserver; the world's",
"matrix-client.matrix.org": "the same homeserver's client endpoint; the world's",
"vector.im": "Element's public identity server; the world's",
"scalar.vector.im": "Element's public integration manager; the world's",
"scalar-staging.vector.im": "Element's staging integration manager, named by the upstream default config; the world's",
"scalar-staging.riot.im": "the same, under its former name; the world's",
"element.io": "Element's bug reports, privacy and cookie pages; the world's",
"gitter.im": "a public room directory; the world's",
"libera.chat": "a public room directory; the world's",
"call.element.dev": "Element Call's public instance; the world's"
}
}, },
{ {
"id": "net", "id": "net",
+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"
} }
+16 -18
View File
@@ -53,40 +53,39 @@
} }
}, },
"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\n" "content": "MINIO_ROOT_USER=meshroot\nMINIO_BROWSER_REDIRECT_URL=https://${bound:route:name-console}\n"
}, },
{ {
"id": "data", "id": "data",
@@ -112,7 +111,7 @@
":9001" ":9001"
], ],
"env-file": [ "env-file": [
"/var/lib/minio/root.env" "${dir:state}/root.env"
], ],
"ports": [ "ports": [
"9000", "9000",
@@ -120,11 +119,10 @@
], ],
"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",
"MINIO_BROWSER_REDIRECT_URL": "https://files.novox.be",
"MINIO_REGION": "eu-west" "MINIO_REGION": "eu-west"
} }
}, },
@@ -134,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",
@@ -144,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"
], ],
+20
View File
@@ -0,0 +1,20 @@
# n8n with what its workflows reach for beyond the upstream image.
#
# The base is named, not pinned here (novox/hq issue 044): module.json's `build.on` declares N8N_BASE
# as the upstream image by digest, and the mesh hands the build its own copy (ADR 0097).
ARG N8N_BASE
FROM ${N8N_BASE}
USER root
# - `media` (GID 2000), with `node` in it: the shared media library is group-writable by the
# operator's media group, and a workflow files downloads into it. A container resource cannot add
# a supplementary group, so the image's own /etc/group carries it. 2000 is the operator's media
# group today; novox/hq 153 proposes reading it from the accessed data (${access:<id>:gid}).
# - uuid, pinned to the version the workflows were written against: Code nodes require() it
# (NODE_FUNCTION_ALLOW_EXTERNAL=*), and a Code node can only require what is installed.
RUN apk add --no-cache shadow \
&& groupadd -g 2000 media \
&& usermod -aG media node \
&& npm install -g uuid@14.0.1
USER node
+76 -17
View File
@@ -18,44 +18,60 @@
} }
}, },
"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": {
"basic-auth": "/var/lib/n8n/basic-auth.secret"
}, },
"accesses": [
{
"id": "media",
"mode": "read-write"
}
],
"listens": [ "listens": [
{ {
"name": "web", "name": "web",
"port": 5682, "port": 5678,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the n8n editor and webhook endpoints over http; the public name n8n.novox.be is a route grant, and route-proxy reaches it on this published port" "why": "the n8n editor, its REST API and the webhook endpoints workflows are triggered through; a public name is the route's"
} }
], ],
"resources": [ "resources": [
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/n8n", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "data", "id": "data",
"type": "directory", "type": "directory",
"path": "/services/n8n/n8n-data",
"mode": "0700", "mode": "0700",
"owner": "1000:1000" "owner": "1000:1000"
}, },
{
"id": "cache",
"type": "directory",
"mode": "0700",
"owner": "999:999"
},
{
"id": "database-secret",
"type": "file",
"path": "${dir:state}/n8n-database.secret",
"mode": "0400",
"owner": "1000:1000",
"content": "${secret:postgres-database}"
},
{ {
"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=n8n.novox.be\nN8N_PORT=5678\nN8N_PROTOCOL=https\nWEBHOOK_URL=https://n8n.novox.be/\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}/\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_FILE=/run/secrets/database\n"
}, },
{ {
"id": "net", "id": "net",
@@ -66,18 +82,61 @@
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "n8n", "name": "n8n",
"image": "n8nio/n8n@sha256:4846eb2f4b874ab04cde7fc1e249d2ddaec66e9aea64439beb2972cfea88e3c0", "artifact": "server",
"network": "n8n", "network": "n8n",
"env-file": [ "env-file": [
"/var/lib/n8n/server.env" "${dir:state}/server.env"
], ],
"ports": [ "ports": [
"5678" "5678"
], ],
"volumes": [ "volumes": [
"/services/n8n/n8n-data:/home/node/.n8n" "${dir:data}:/home/node/.n8n",
"${dir:state}/n8n-database.secret:/run/secrets/database:ro",
"${access:media}:/media-library"
]
},
{
"id": "cache-server",
"type": "container",
"name": "n8n-redis",
"image": "redis@sha256:8a1efc5f479551822b47424ccae982026b633f28818eab0387348120a61e10e2",
"network": "n8n",
"args": [
"redis-server",
"--appendonly",
"yes"
], ],
"secrets-in-environment": "n8n's loader honours <VAR>_FILE for every setting; convertible, awaiting a bed that proves it (N8N_BASIC_AUTH_* was removed in n8n 1.0 and is likely dead)" "volumes": [
"${dir:cache}:/data"
]
},
{
"id": "browser",
"type": "container",
"name": "n8n-selenium",
"image": "selenium/standalone-chrome@sha256:9ae1c78e9b2ca9fe4b22e57873b5ee34aeb8e814e3122293eef4c3abe4c5f448",
"network": "n8n",
"env": {
"SE_ENABLE_TRACING": "false",
"SE_NODE_MAX_SESSIONS": "5",
"SE_NODE_OVERRIDE_MAX_SESSIONS": "true"
}
}
],
"build": {
"on": [
{
"arg": "N8N_BASE",
"image": "n8nio/n8n@sha256:4846eb2f4b874ab04cde7fc1e249d2ddaec66e9aea64439beb2972cfea88e3c0"
}
],
"artifacts": [
{
"name": "server",
"kind": "image",
"from": "Dockerfile"
} }
] ]
}
} }
+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"
], ],
+15 -9
View File
@@ -5,9 +5,15 @@
"flows.deployed" "flows.deployed"
], ],
"own-secrets": { "own-secrets": {
"admin": "/var/lib/mesh/nodered/admin", "admin": {
"api-token": "/var/lib/mesh/nodered/api-token", "path": "${dir:mesh-state}/admin",
"broker": "/var/lib/mesh/nodered/broker" "taken": "at-start"
},
"api-token": {
"path": "${dir:mesh-state}/api-token",
"taken": "at-start"
},
"broker": "${dir:mesh-state}/broker"
}, },
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
@@ -25,8 +31,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 +96,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 +106,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 +126,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": [
+1 -1
View File
@@ -26,7 +26,7 @@
"port": 9070, "port": 9070,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the document server over http; the public name office.novox.be is a route grant, and route-proxy reaches it on this published port" "why": "the document server over http; its public name is a route grant, and route-proxy reaches it on this published port"
} }
], ],
"resources": [ "resources": [
+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",
+8 -5
View File
@@ -15,7 +15,7 @@
} }
}, },
"binds": { "binds": {
"route": "/var/lib/photos-eef/route.json" "route": "${dir:state}/route.json"
}, },
"listens": [ "listens": [
{ {
@@ -23,15 +23,15 @@
"port": 4012, "port": 4012,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the eef photos client site over http; the public name eef.novox.be is a route grant, and route-proxy reaches it on this published port" "why": "the eef photos client site over http; its public name is a route grant, and route-proxy reaches it on this published port"
} }
], ],
"resources": [ "resources": [
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/photos-eef", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "net", "id": "net",
@@ -46,7 +46,10 @@
"network": "photos-eef", "network": "photos-eef",
"ports": [ "ports": [
"80" "80"
] ],
"names-on-purpose": {
"registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)"
}
} }
] ]
} }
+8 -5
View File
@@ -15,7 +15,7 @@
} }
}, },
"binds": { "binds": {
"route": "/var/lib/photos-filip/route.json" "route": "${dir:state}/route.json"
}, },
"listens": [ "listens": [
{ {
@@ -23,15 +23,15 @@
"port": 4013, "port": 4013,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the filip photos client site over http; the public name filip.novox.be is a route grant, and route-proxy reaches it on this published port" "why": "the filip photos client site over http; its public name is a route grant, and route-proxy reaches it on this published port"
} }
], ],
"resources": [ "resources": [
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/photos-filip", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "net", "id": "net",
@@ -46,7 +46,10 @@
"network": "photos-filip", "network": "photos-filip",
"ports": [ "ports": [
"80" "80"
] ],
"names-on-purpose": {
"registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)"
}
} }
] ]
} }
+18 -12
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": [
{ {
@@ -43,20 +43,20 @@
"port": 4001, "port": 4001,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the admin client site over http; the public name photos.novox.be is a route grant, and route-proxy reaches it on this published port" "why": "the admin client site over http; its public name is a route grant, and route-proxy reaches it on this published port"
} }
], ],
"resources": [ "resources": [
{ {
"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,12 +72,15 @@
"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"
], ],
"secrets-in-environment": "the application's own code reads MONGO_URL and MINIO_SECRET from the environment (photos server/src/config.js); converting is that repository's change" "secrets-in-environment": "the application's own code reads MONGO_URL and MINIO_SECRET from the environment (photos server/src/config.js); converting is that repository's change",
"names-on-purpose": {
"registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)"
}
}, },
{ {
"id": "admin-client", "id": "admin-client",
@@ -87,7 +90,10 @@
"network": "photos", "network": "photos",
"ports": [ "ports": [
"80" "80"
] ],
"names-on-purpose": {
"registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)"
}
} }
] ]
} }
+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": {
+8 -8
View File
@@ -11,7 +11,7 @@
"port": 9000, "port": 9000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the dashboard over http; routed, so the proxy reaches it here" "why": "the dashboard over http; its public name is a route grant and the proxy reaches it here"
}, },
{ {
"name": "web-tls", "name": "web-tls",
@@ -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"
} }
} }
+14 -15
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"
}, },
{ {
@@ -80,7 +79,7 @@
{ {
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "mesh-store", "name": "postgres",
"image": "pgvector/pgvector@sha256:cf134a767f474095eeba57e0117be8e568e011a63f33fbf252f14c9b760f8e6f", "image": "pgvector/pgvector@sha256:cf134a767f474095eeba57e0117be8e568e011a63f33fbf252f14c9b760f8e6f",
"env": { "env": {
"POSTGRES_PASSWORD_FILE": "/run/secrets/superuser", "POSTGRES_PASSWORD_FILE": "/run/secrets/superuser",
@@ -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": [
+18 -17
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",
@@ -162,7 +162,8 @@
"kind": "image", "kind": "image",
"from": "Dockerfile", "from": "Dockerfile",
"context": { "context": {
"repository": "https://git.novox.be/novox/mesh-controller.git", "seat": "git",
"repository": "novox/mesh-controller",
"ref": "main" "ref": "main"
} }
}, },
+11 -8
View File
@@ -5,8 +5,11 @@
"container-runtime" "container-runtime"
], ],
"own-secrets": { "own-secrets": {
"secret": "/var/lib/mesh/searxng/secret", "secret": {
"broker": "/var/lib/mesh/searxng/broker" "path": "${dir:mesh-state}/secret",
"taken": "at-start"
},
"broker": "${dir:mesh-state}/broker"
}, },
"listens": [ "listens": [
{ {
@@ -21,8 +24,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 +88,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 +98,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": [
@@ -1,5 +1,5 @@
{ {
"module": "novox.be", "module": "website",
"version": "1", "version": "1",
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
@@ -14,38 +14,41 @@
} }
}, },
"binds": { "binds": {
"route": "/var/lib/novox.be/route.json" "route": "${dir:state}/route.json"
}, },
"listens": [ "listens": [
{ {
"name": "web", "name": "web",
"port": 4000, "port": 8080,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the public website over http; the public name novox.be is a route grant, and route-proxy reaches it on this published port" "why": "the public website over http; its public name is a route grant, and route-proxy reaches it on this published port"
} }
], ],
"resources": [ "resources": [
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/novox.be", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "net", "id": "net",
"type": "network", "type": "network",
"name": "novox-be" "name": "website"
}, },
{ {
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "novox-be", "name": "website",
"image": "registry-api.novox.be/novox/www@sha256:aa7ed20a293e1d7444c5c5d59b6d8bdb382bad159559a22e006810e379cae69c", "image": "registry-api.novox.be/novox/www@sha256:aa7ed20a293e1d7444c5c5d59b6d8bdb382bad159559a22e006810e379cae69c",
"network": "novox-be", "network": "website",
"ports": [ "ports": [
"8080" "8080"
] ],
"names-on-purpose": {
"registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)"
}
} }
] ]
} }