Commit Graph
184 Commits
Author SHA1 Message Date
jschoubben b3de7e7944 minio: declare region eu-west, don't leave it as live-only drift
serves.s3-bucket.region still said us-east-1 (PR #58 already fixed this,
unmerged) while the live mesh-minio sidecar had MESH_MINIO_REGION=eu-west
set out-of-band, not in the manifest at all — and the actual minio server
had no region configured whatsoever (mc admin config get region: empty),
apparently lost across a container recreation since nothing declared it.
Every s3-bucket consumer binding ${bound:s3-bucket:region} was reading
the stale us-east-1 declaration regardless of what was actually live.

Declares MINIO_REGION on the server container and MESH_MINIO_REGION on
the sidecar, matching the static serves declaration, so this is mesh-
managed and durable rather than a manual mc admin config or docker env
override that the next recreation silently drops.
2026-09-25 15:10:30 +02:00
jschoubben 5d13a5078c minio: install mc in the runtime image — the provisioner needs it to run
mesh-minio's s3-bucket provisioner shells out to mc to create buckets and
service accounts on the live server, but mc was never in this module's own
runtime image, only in minio's own. It's been silently retrying 'spawn mc
ENOENT' forever, so every s3-bucket grant reached the control-plane layer
(store.json, sealed secret) without the credential ever actually existing
on minio — nextcloud's live instance just hit this as InvalidAccessKeyId
on a real user session.

Copies mc from minio's own image (docker.io/pgsty/minio, already pinned
and pulled as this module's server container) rather than introducing a
new base — mc there is a working, already-verified binary. /usr/bin/mc is
a symlink to mcli; both are copied so it resolves.
2026-09-25 14:37:23 +02:00
jschoubben 61eca75315 nextcloud: pull the S3 region from the binding, not a hardcoded literal
the s3-bucket binding already carries serves.region (same mechanism as
at/port); ${bound:s3-bucket:region} tracks whatever minio is actually
configured with instead of a copy that can drift
2026-09-25 14:15:40 +02:00
jschoubben df0e9017e0 nextcloud: set OBJECTSTORE_S3_REGION to match minio's actual region
minio runs with MINIO_REGION=eu-west; nextcloud's S3 config never set a
region, so every object write (avatars, file writes) failed signature
validation with AuthorizationHeaderMalformed, surfacing as Internal Server
Error on real page loads
2026-09-25 14:12:05 +02:00
jschoubben b60dd5e3e3 nextcloud: use a module-owned mesh-admin account instead of 'admin'
the migrated data has no literal 'admin' account — HAL's real admin login
is a personal account (jochens), not a generic one. Resetting that would
touch a real user's own credential, so the module gets its own dedicated
admin-group service account instead, same pattern as the minio per-module
service accounts. mesh-admin was created once by hand on novox to match
this manifest for the already-migrated data; a genuinely fresh install
seeds it automatically via NEXTCLOUD_ADMIN_USER/NEXTCLOUD_ADMIN_PASSWORD.
2026-09-25 13:51:40 +02:00
jschoubben b865978e19 nextcloud: use a named build stage for docker:cli, not ARG-in-COPY-from
the legacy (non-BuildKit) docker build this host runs doesn't expand ARGs
inside COPY --from — only FROM. Give it its own named stage instead
2026-09-25 13:49:02 +02:00
jschoubben cdbc850c52 nextcloud: declare docker:cli as a pinned build.on base
build refused to reach docker:cli implicitly (novox/hq ADR 0097); pin it by
digest and thread it through as DOCKER_CLI, redeclared in the final stage
since args declared before the first FROM don't carry past it
2026-09-25 13:48:00 +02:00
jschoubben e2b3723dd9 nextcloud: fix hardcoded sidecar port and missing docker CLI in runtime image
- MESH_NEXTCLOUD_URL hardcoded :80 instead of the mesh-assigned ${port:80}
- sidecar's occ() shells to docker exec but the docker CLI binary was never
  present in the runtime image, only the mounted socket
2026-09-25 13:47:03 +02:00
jschoubben 5f74c41a3e nextcloud: give the admin password a real _FILE variant, not an env-file
The mesh's own check caught it: an env-file-loaded secret still reaches
the process environment, readable via docker inspect and /proc (hq
04-ISSUES/041) -- the same class of exposure the file-based delivery
exists to avoid. Added MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE support to the
client, matching the pattern the minio client already uses, and mounted
the sealed admin secret directly rather than writing it into an env-file.
2026-09-25 13:42:21 +02:00
jschoubben 4329ca9392 nextcloud: deliver the admin password to the sidecar too
The sidecar's own client needs MESH_NEXTCLOUD_ADMIN_PASSWORD to list
shares over the OCS API, but the runtime container's env/volumes never
carried it -- only the server container did. Delivered the same way every
other sealed value in this manifest already is: a generated env-file with
the ${secret:admin} substitution, not a raw value in the container's env.
2026-09-25 13:41:17 +02:00
jschoubben 066254e5c4 nextcloud: pin an image whose PHP matches this Nextcloud version
The pinned digest resolved to a PHP 8.5.10 image; Nextcloud 30 refuses to
run above PHP 8.4. Repinned to the current digest for the nextcloud:30 tag
(matches HAL's own NEXTCLOUD_VERSION), which carries PHP 8.3.28 -- the
same version the data being migrated was actually running under.
2026-09-25 13:39:16 +02:00
jschoubben 35e8a3fe19 minio: repin to pgsty's fork, build the runtime sidecar, move data off HAL's live drive
minio/minio and minio/mc were pulled from all public registries on 2026-09-11;
pgsty's fork is the working replacement (hq issue 113). The runtime sidecar
had no Dockerfile and no build section at all — added, following the same
tsc-over-client/tools/provisioner shape every other converted module uses.

The data resource pointed straight at /services/minio/data/data1-1, one of
HAL's live 8-drive erasure-coded array — starting this module would have
written into production storage the mesh doesn't own. Moved to a fresh,
empty, mesh-owned directory; the actual data migration happens over the S3
API (rclone), not by sharing a disk path.
2026-09-24 17:55:49 +02:00
jschoubben 5c3781d8c2 Merge pull request '4 modules: persistent data is a directory bind, never a named volume' (#54) from fix/persistent-data-is-a-directory-bind-not-a-volume into main 2026-09-24 14:24:15 +00:00
jschoubben 6a3e0ab9d3 4 modules: persistent data is a directory bind, never a named volume
hq ADR 0107 / issue 115. HAL's own postgres and lavinmq both used a
directory bind (./db-data, ./data) for exactly this data -- the mesh's
adoption of them, three weeks ago, switched to a named Docker volume
instead, and searxng/distribution followed the same pattern since.

A named volume survives ordinary container recreation, same as a
directory bind -- that was never the problem. The problem is everything
else: docker rm -fv, docker volume rm, and docker system prune --volumes
all target it (one flag away from the docker rm -f this migration already
uses routinely); it is invisible to every tool this migration has used all
night (ls, find, grep across /var/lib, /services); and nothing outside
Docker's own volume machinery can back it up or notice it growing.

mesh-store carries the sharpest version: every database migrated tonight,
including keycloak's, live inside it.

Data already copied and verified on novox before this merges:
- mesh-registry-data -> /var/lib/mesh-registry (11G, diff -rq clean)
- mesh-broker-data -> /var/lib/mesh-broker (37M, cp -a)
- mesh-broker-tls -> /var/lib/mesh-broker-tls (12K, cp -a)
- mesh-store-data -> /var/lib/mesh-store (1.7G) -- mesh-store stopped
  cleanly first, so the final copy is crash-consistent, not a live-file
  copy of a running postgres; diff -rq clean after.
- searxng/valkey: not yet assigned anywhere, manifest-only fix, nothing
  to copy.

Old named volumes left in place, not deleted, as the rollback path.
2026-09-24 16:12:03 +02:00
jschoubben 3e8ecfa4eb Merge pull request '11 modules: a container's ports say the software's port, not the machine's' (#53) from fix/091-ports-machine-side-is-the-mesh-to-assign into main 2026-09-24 13:53:34 +00:00
jschoubben 12f885ffc5 11 modules: a container's ports say the software's port, not the machine's
hq issue 091, measured 2026-09-22: 14 of 46 modules with containers fix the
machine side of a published port in their own manifest -- a fact about one
machine (which port HAL happened to publish it on) written into a
definition meant for any node. ADR 0038 already says the mesh assigns the
machine side and a module says only what it needs; the machinery already
does it (internal/inventory/ports.go's PortFor, declaration.go's
publishedOn rewrites a bare port automatically). These 14 just never
complied.

Fixed 11 of them -- stripped to the bare software port, letting assignment
take over: de-spiegel, gitea, hello-web (the demo module), mailu, mssql,
n8n, novox.be, only-office, photos, photos-eef, photos-filip.

Left alone, the two defensible kinds the issue names: postgres/lavinmq/
distribution (foundation, genesis-rewritten per ADR 0100 -- the number in
the manifest is a default, not a claim) and unifi (protocol/
device-discovery fixes the number; nothing else can find the controller).

gitea was a live one, not just a tidiness fix: its manifest said 2222:22,
but the actual adopted, running container is on 222. Harmless while held,
but the next 'take' would have recreated it on the wrong port and broken
SSH git access. Recorded 222 as novox's own setting for it (settings set
gitea -node novox) so that doesn't happen.
2026-09-24 15:45:58 +02:00
jschoubben 135101ce6e postgres: fix the port override properly — a twin variable, not a raw substitution
The first commit on this branch embedded ${seat:mesh-store:5432} directly
inside MESH_PROVISION_POSTGRES's URL. That placeholder resolves to an empty
string whenever mesh-store is on its own default port (novox/hq
internal/catalogue/seat_into.go: 'the mesh raised on the catalogue's own
ports never gives them a setting at all') -- which produces a malformed
connection string (host:/postgres) on exactly the common case, a fresh,
non-adopted mesh. It only worked here because novox's mesh-store happens to
be adopted at a non-default port.

mesh-controller's own manifest already has the right shape for this --
internal/envfile/port.go's NAME / NAME_PORT twin, composed by
mesh-controller's Placed(): the base value keeps its own port; a separate
_PORT variable carries the override, spliced in only when it says
something, and left alone -- not a fault -- when it's an unfilled
placeholder (a manifest ahead of the running controller).

Reverted the manifest to its original base value, added
MESH_PROVISION_POSTGRES_PORT as the twin, and taught client.ts (the only
consumer -- both tools/ and provisioner/ import it) the same precedence
envfile.Placed uses. Checked: no other file in the module reads
MESH_PROVISION_POSTGRES directly.
2026-09-24 15:27:16 +02:00
jschoubben 088022d63b postgres: the provisioner connects to mesh-store's actual port, not a hardcoded 5432
MESH_PROVISION_POSTGRES was a literal connection string naming port 5432 --
correct only when mesh-store happens to run on the mesh's own default. On
novox, mesh-store was adopted in place at HAL's original port (6852), and
the provisioner has been retrying-and-failing against 127.0.0.1:5432 ever
since, for every consumer including ones that already exist (gitea, umami,
mesh-catalog), not just a new grant.

Fixed with the same ${seat:mesh-store:5432} template mesh-controller's own
manifest already uses for the identical connection. No other module needed
this fix checked -- lavinmq's MESH_PROVISION_LAVINMQ already used
127.0.0.1:15672 unconditionally, but the broker's management port is fixed
by the module itself (127.0.0.1:15672 in lavinmq/module.json's own ports),
not by adoption, so it isn't the same bug.
2026-09-24 15:24:07 +02:00
jschoubben aaf341a2d8 gitea: the token needs read:user, not just write:repository and write:issue
Deployed #49 and the watcher immediately broke: GET /user/repos answered 403,
'required=[read:user]' — confirmed live against the running forge (1.27.3).
That route sits under gitea's user scope category despite listing
repositories, not repository as assumed.

Also gives the fake forge real scope enforcement on /user/repos, which is
why the original PR's test suite didn't catch this: it only checked the
token's value was valid, never that it carried the required scope.
2026-09-24 11:43:46 +02:00
jschoubben d036b43bfc Merge main into fix/gitea-mints-its-token to pick up the resolver conversion and artifact-store seat fix 2026-09-24 10:40:57 +02:00
jschoubben 3d81bf41c6 Convert the resolver from the module it replaces: forward, answer at 127.0.0.1, point the runtime at it
Read against hal/modules/dnsmasq-app (hal dnsmasq-app conversion, hq 08-connectivity).
On the machines it runs, the predecessor's dnsmasq answers every name: the mesh's own
itself, the rest forwarded to 1.1.1.1 and 8.8.8.8, its module's defaults; resolv.conf
names it alone at 127.0.0.1, and the container runtime's dns is the machine's tunnel
address, so the host and every container resolve the world through it. The nox module
forwarded nothing, listened on 127.0.0.55 — a convention of its own beside the one every
machine already followed — and read a machines file that the module's `facts` already
asks the mesh for, so taking it would have left an adopted machine with a resolv.conf
pointing at an address nothing answered on, and no upstream for anything else.

Now the resolver keeps `no-resolv` (the documented loop — finding its own address in
resolv.conf and becoming its own upstream — stays impossible) and forwards to the same
two explicit upstreams; listens on mesh0 and 127.0.0.1, which systemd-resolved does not
hold; requires `mesh-addressing`, since its data is the mesh's addresses; and writes the
runtime's `dns` into daemon.json beside whatever the machine had (ADR 0102), at this
machine's own address — `${machine:address}`, new in the controller. The runtime is not
restarted for it: it reads the key at start, not on reload, and a restart stops every
container; on the machine this replaces the value is already there.

resolv-conf names the resolver alone, as the predecessor's file did; its placeholder second
line was a fallback nothing ever reached. resolved-split-dns follows the address. The
mesh's suffix as a local domain comes with the machines file, so a mesh name the resolver
does not know is refused here rather than asked upstream. mDNS is not carried: no module
does it and the design says mesh names are not multicast names.
2026-09-24 01:09:23 +02:00
jschoubben ed094031fd The forge mints its own API token with the admin account the vault delivers
The runtime beside the forge served 0 tools: GiteaClient.fromEnv required a token
(settings or MESH_GITEA_TOKEN), nobody had one to give — the mesh raised the forge —
and putting one in settings would store a secret in plaintext in the inventory. So
the fifteen tools registered nothing and the watcher logged "not watching".

What the mesh does deliver is the admin account: a login the manifest names and a
password the vault minted and the host unsealed into a file (ADR 0086). That is
enough to mint a token, so the module does (hq issue 100, the forge's tools):
POST /users/{admin}/tokens over basic auth, scoped to write:repository and
write:issue — the least the tools and the repo watcher need — kept at 0600 in the
module's own state (/var/lib/mesh/gitea/state, a new directory resource the runtime
mounts writable), read back on the next start, and minted afresh when the forge
answers 401 to it or the kept file is gone. A forge whose data came from the
predecessor has no mesh-admin: that is reported in plain words on every poll until
it clears, once per reason, not crash-looped. A configured token still wins and is
never minted over.

The mint happens on the first call, not at registration: a contributor is
synchronous, and a forge not yet answering must not keep the runtime from serving.
One source per kept file in a process, or the watcher and the tools would each
renew on a 401 and drop the other's token by name.
2026-09-23 23:48:36 +02:00
jschoubben 833d8ec3ec The artifact store's seat is one per mesh
Review of the registry work found it node-scoped: a second `distribution` on another
machine resolved cleanly there, and only afterwards did the mesh notice `artifact-store`
offered by two nodes, with every consumer elsewhere refusing to choose. Worse, a node-scoped
requirement with one candidate installs that candidate, so anything that wanted the store
beside it would have raised a fresh, empty store on the wrong machine first.

There is one store in a mesh, which was already the effective rule; the claim now says it
where a second one is assigned, by name, instead of leaving consumers to discover it.
2026-09-23 23:40:54 +02:00
jschoubben cae0d00a7d Convert mssql from the module it replaces, not from a blank page
Read against hal/modules/mssql: the predecessor publishes 4848:1433 and its connections
block hands every consumer localhost:4848; its data has lived in /services/mssql/data
since it was installed — 4.6 GB of it. The nox module had 1433, db-data, and a pin three
builds behind, so taking it would have moved the port every consumer was told about,
started on an empty directory, and downgraded the engine at the same time.

db-data was not a convention: only this module and mongodb used it, and both predecessors
say data. Now nothing moves at the cutover.
2026-09-23 20:22:34 +02:00
jschoubben b69edbb16f Pin the analytics module to the version the machine it replaces is running
The pin was a month old — 2026-08-20 against the 2026-09-17 image the container runs.
Third module in a row whose pin had aged into a downgrade; the pattern is hq issue 099.
2026-09-23 02:47:23 +02:00
jschoubben a3eca8f4e6 Pin the package registry to the version the machine it replaces is running: 6.10.3, not 6.10.1
The rule the forge's cutover taught: a module that takes over a running service must not
carry an older image than the one running, or the cutover is a downgrade nobody asked for.
6.10.4 exists; moving to it is an upgrade and its own act.
2026-09-23 02:24:07 +02:00
jschoubben a29475617a Pin the forge to the version the machine it replaces is running: 1.27.3, not 1.22.6 2026-09-23 00:28:11 +02:00
jschoubben bd5b349a0d route-adapter: provide route by writing into the predecessor's proxy (hq ADR 0104)
A node being adopted cannot take a web module: every module reachable by
name requires route, the mesh's only provider of it binds the two public
ports, and the predecessor's proxy holds them and serves every public
name there. Stopping the predecessor to break the circle darkens every
name at once, with every certificate to re-obtain in the same window.

So this answers the same provision without binding anything. It provides
route and receives the same contributions file, and writes each
contribution as one route file where the predecessor's file provider
reads, naming the predecessor's own certificate resolver so no
certificate is asked for. It removes a file it wrote when its
contribution goes and never touches a file it did not write — the name
and a marker inside both have to say it is the mesh's.

A step, not a daemon: run-once, re-run by restart-on over the received
file and the settings. The predecessor's dynamic directory is a node
setting, because it is a fact about one machine.

Migration scaffolding with a stated end: assigned only on an adopted
node, deleted when the predecessor's proxy retires.
2026-09-23 00:11:21 +02:00
jschoubben 5eb1baf5fe Keep the vector extension the predecessor's database had: the store runs the pgvector build of the same major 2026-09-22 23:30:21 +02:00
jschoubben cf30d4b3d0 The forge's sidecar dials the port the machine put the forge on (hq issue 088)
MESH_GITEA_URL named 3000 outright. The forge's container publishes 3000 without
fixing the machine side, so the mesh assigns it — and on a node given that port
as a setting it is the operator's number. The mapping that lets the forge go on
binding 3000 does nothing for a caller dialling the machine's loopback, so the
sidecar dialled a port nothing was listening on wherever the two differed.

${port:3000} is the mesh's answer to exactly that question, and it now resolves
in a container's environment as it always has in a file. The forge declares it
listens on 3000, so it may ask.

Still names 3000: the route contribution (hq issue 089) and the `listens` and
`ports` entries, which are the software's own number and belong there.
2026-09-22 22:36:34 +02:00
jschoubben b2e29871fd Protect the address the builder sends its registry password to
`at` was settable. A node setting could point the builder's package binding at
any host, and the builder sends its registry credential there as basic auth — so
a setting meant for a port was a way to hand the password to somebody else.

novox/hq 04-ISSUES/085
2026-09-22 21:56:51 +02:00
jschoubben d63f006cb8 The builder's package binding takes its port from the node, not from the manifest
The forge is raised by hand at genesis, before any module provides
`package-registry`, so the builder carries a binding instead of resolving one —
and the port in it was rewritten, as text, by the installer. A later
registration of this manifest from the catalogue put 3000 back, silently, and
pointed the builder and its registry credential at whatever holds that port.

Made settable instead: the port is a per-node setting the controller holds, and
the catalogue's number is only its default. `provision`, `from` and `as` are
protected — a setting here is about where the forge answers, never about who the
binding is with.

novox/hq 04-ISSUES/085, ADR 0100
2026-09-22 21:39:54 +02:00
jschoubben 90104d0818 Reload the filter on a rule change rather than restart it, so the node is never unfiltered in between (hq ADR 0102) 2026-09-22 19:47:43 +02:00
jschoubben 84012fab2e Make the stock nftables unit's stop delete only the mesh's table on nodes that still have it enabled (hq ADR 0100) 2026-09-22 18:06:15 +02:00
jschoubben 0c37d7389d Guard the store and management ports on adopted nodes, and load the filter through a unit that never flushes the ruleset (hq ADR 0100) 2026-09-22 17:32:32 +02:00
jschoubben 431627c510 baserow: its data directory is 0755, as the image ships it — the cache it runs for itself does so as another user, who must reach its own directory beneath (issue 081) 2026-09-22 13:46:01 +02:00
jschoubben 93634041db baserow: its texts no longer name the shared cache it stopped taking 2026-09-22 12:34:58 +02:00
jschoubben 24cbac816d n8n and baserow no longer take the shared cache: neither can keep its keys and channels under the login it is granted — baserow runs its own, n8n in its shipped mode needs none (novox/hq issue 081) 2026-09-22 12:26:33 +02:00
jschoubben c71bbd497f redis: INFO is allowed back — client libraries ask it at connect, and it reads nothing a consumer keeps 2026-09-22 02:03:23 +02:00
jschoubben 421f4d8577 redis: a consumer's ACL loses the dangerous category — a key pattern does not confine FLUSHALL (novox/hq issue 080) 2026-09-22 01:39:29 +02:00
jschoubben 6fb2003b8d route-proxy: the trust gate names the binding it reads and runs again when the authority moved; the server follows it (ADR 0099) 2026-09-21 23:25:06 +02:00
jschoubben ac651c7ac2 route-proxy: the trust container names its artifact 2026-09-21 22:55:35 +02:00
jschoubben 1c964cd571 route-proxy: the trust gate retries with a timeout and checks for a certificate; its images are declared artifacts (ADRs 0096, 0097, 0098) 2026-09-21 22:53:29 +02:00
jschoubben 28b8feebfc The authority makes its own root at first start, and the proxy fetches it through a gate
step-ca's root certificate, its key and that key's password were own secrets — random
bytes the mesh minted, which no certificate is (novox/hq 04-ISSUES/076). The mesh
mints only the CA password now; step-ca makes its root at first start and serves it
at /roots.pem, which the manifest now names beside the ACME directory. The route
proxy fetches that root over the mesh network in a run-once step before it starts,
instead of being handed a served fact that could only be written before anything ran
(ADR 0098).
2026-09-21 22:26:28 +02:00
jschoubben 82256fcdb0 The builder runs on the machine's network: it copies images into the mesh's registry itself now
The copy between registries (ADR 0096) reaches the mesh's registry over HTTP from
inside the builder's container, where loopback on the default bridge is not the
machine; docker push never noticed because it went through the machine's daemon.
The builder already holds the runtime's socket, so the host network adds nothing
it did not have.
2026-09-21 22:24:43 +02:00
jschoubben facd41806d Five modules keep their own secrets from the vault, under local names; route-proxy declares its bases
gitea, umami, influxdb, icecast and mailu require a secret and keep each of theirs
under a local name (novox/hq ADR 0094); the broker account stays their own. The
route proxy's recipe starts FROM the bases its manifest declares (ADR 0097).
2026-09-21 22:16:10 +02:00
jschoubben a514d9827c The controller's manifest lives in the controller's repository, not here
ADR 0069 put it there; genesis now takes it from the build it runs (mesh-host,
novox/hq 04-ISSUES/072). This copy was read by nothing else and had already drifted.
2026-09-21 15:17:47 +02:00
jschoubben 1289510538 mongodb names its secrets' owner: the image drops to its own user before it reads the password file
The official entrypoint re-executes itself as mongodb (uid 999) and only then reads
MONGO_INITDB_ROOT_PASSWORD_FILE, so a root-owned 0600 file is 'Permission denied' at line 83 and
the server never starts. secrets-owner is the mechanism ADR 0086 gives for exactly this.
2026-09-21 13:43:41 +02:00
jschoubben 50b639ff52 The env-file references to files the conversion removed go with them 2026-09-21 12:43:41 +02:00
jschoubben 0b2f3bfbd3 grafana stays a declared exception until a bed exercises its admin password 2026-09-21 12:31:59 +02:00