feat(bus): land the single-host loopback arm of the zenohd profile

Phase 5.1 of `.settings/features/feature-zenoh-transport.md`, split per the
Security & Identity council's chair call on meridian-nok3. The held commit
060b755c5 carried Compose *and* Helm; this lands only the arm the relay can
actually boot against, and holds the rest.

WHAT LANDS: the root `docker-compose.yml` `bus` profile (opt-in, default off),
`deploy/compose/zenoh/zenohd.json5` as the one router config both stacks will
mount read-only, `docker-compose.override.yml.example`, `.env.example`,
`.env.local.example`, `run.sh` with `zenoh` added to `PORT_KEYS`,
`.settings/run-profiles/local.yaml`, and the `env-doctor` bus report. Every
endpoint it renders is `tcp/localhost:<port>`.

WHAT IS HELD: `deploy/charts/**`, `deploy/compose/compose.yml`,
`compose.dev.yml`, `deploy/compose/.env.example` and
`deploy/compose/README.md`. Those render `tcp/zenohd:7447` and
`tcp/<pod>.<service>:7447`, and `ZenohBusConfig::validate` refuses a reachable
endpoint while no lane floor exceeds the authenticated link profile — which
under the in-deployment floors is unconditional, and which `from_env` cannot
raise by design. Confirmed against the binary built from this tree, not only
against `a_reachable_endpoint_is_refused_while_the_link_profile_meets_every_floor`:

    MERIDIAN_BUS=zenoh MERIDIAN_ZENOH_ENDPOINTS=tcp/zenohd:7447 -> exit 1,
    "endpoint `tcp/zenohd:7447` is reachable beyond this host ... an
     event-injection primitive"

The held chart went further and made that state the documented default:
`_validate.tpl` failed the render unless endpoints were supplied whenever
`bus.mode != redis`. Shipping it would have pushed operators into a guaranteed
crash rather than merely allowing one.

Three findings from zenohd's own source shape the router config, none visible
from its documented surface. zenohd runs `config.adminspace.set_enabled(true)`
and `config.plugins_loading.set_enabled(true)` unconditionally after loading
the file, so the levers that hold are `--adminspace-permissions none`,
`plugins_loading.search_dirs: []`, an empty `plugins: {}`, and never `-P`.
Multicast scouting is force-ENABLED when the key is unset, so
`--no-multicast-scouting` is what survives a config swap, while gossip has no
CLI flag at all — which is why mounting the file is mandatory rather than a
convenience. And `transport.shared_memory.enabled` defaults TRUE in Zenoh 1.x,
so it is set explicitly false; Phase 6 is unscheduled and gated on a measured
crossover plus memlock/CAP_IPC_LOCK.

`run.sh`, `PORT_KEYS` and `local.yaml` move in this commit and not before: a
generated `zenohd:` override block against a base file with no `zenohd` service
makes `docker compose config` fail outright. The port is allocated whether or
not the profile is on, so enabling it later moves nothing.

Two fixes on top of the held commit. Its `.env.local` and override heredocs are
unquoted and its new comments carried raw backticks, so every `./run.sh`
invocation ran `bus` as a command — three `bus: command not found` lines on
stderr and the word deleted from the generated comment. Backticks are escaped
the way the surrounding lines already do it. And its `.env.example` block
re-documented `MERIDIAN_BUS` a second time, claiming `redis` is the only
shippable value and that the other modes exist "not in a deployable artifact";
that was already only half true and is now false, so the block points at the
canonical one and states the real boundary, which is loopback.

Also carried over: `env-doctor` exited 1 on any tree without a `.env`, because
the recipe exports `COMPOSE_ENV_FILES=.env` while `.env` is gitignored — the
tool you reach for BECAUSE the layers are confusing died before printing a
port. It now reports what was asked for, then drops the names that do not
exist.

Gates, each read as an exit code rather than from its output:
  just compose-check            0 with COMPOSE_PROFILES=...,bus and 0 without
                                (63 assertions, 0 failed, both ways)
  just env-doctor               0 both ways, including on a tree with no .env
  docker compose --profile bus config   0; `published: "7447"` appears once,
                                so `ports: !override` replaced rather than
                                appended
  just launcher-check           0
  ./run.sh ports --fresh-ports  0, and reproduces the saved allocation exactly
                                across three runs — every port equals its
                                profile starting point, zenoh included
  just zenoh-check              0
  just check-docker-context     0

Signed-off-by: Joshua Belke <admin@aipowergrid.io>
Signed-off-by: Joshua Belke <joshua@innovationhub-act.org>
This commit is contained in:
Josh Belke 2026-08-20 00:35:28 -04:00
commit 6893ecfbd5
10 changed files with 346 additions and 8 deletions

View file

@ -42,10 +42,15 @@
# Compose profiles — which optional services start
# -----------------------------------------------------------------------------
# Core services (postgres, dragonfly, minio*) have no profile and
# always start. The three below are opt-in. Kept enabled here so a fresh
# `cp .env.example .env` brings up the same stack as before profiles existed;
# trim or comment out for a leaner core-only stack.
# always start. The extras below are opt-in. tools/auth/observability stay
# enabled here so a fresh `cp .env.example .env` brings up the same stack as
# before profiles existed; trim or comment out for a leaner core-only stack.
# tools → adminer (:8082) · auth → keycloak (:8180) · observability → prometheus (:9090)
# bus → zenohd (:7447) — DEFAULT OFF, do not add it here
#
# `bus` is deliberately absent from this line. zenohd carries events only, and
# only once MERIDIAN_BUS names a Zenoh mode; starting a router that nothing
# talks to is inventory, and the profile is how that stays a choice.
COMPOSE_PROFILES=tools,auth,observability
# -----------------------------------------------------------------------------
@ -105,7 +110,11 @@ REDIS_URL=redis://localhost:6379
#
# `zenoh` and `shadow` need a relay BUILT with the `zenoh` feature
# (`cargo build -p meridian-relay --features zenoh`). A build without it
# refuses them by name rather than falling back to Redis.
# refuses them by name rather than falling back to Redis. The published relay
# image carries the feature — `Dockerfile` and `REMAPPING/cicd/Dockerfile` both
# build with it — so these names are readable by the artifact, not only by a
# local build. Carrying the feature is not the same as being reachable: the
# endpoint rule below still confines this to one host.
# MERIDIAN_BUS=redis
# Comma-separated Zenoh endpoints this pod connects to. REQUIRED whenever
@ -356,6 +365,44 @@ MERIDIAN_S3_ADDRESSING_STYLE=path
# REDUCTSTORE_SYNC_INTERVAL=60
# REDUCTSTORE_LICENSE_DIR=./.secrets
# -----------------------------------------------------------------------------
# Event bus — zenohd router (COMPOSE_PROFILES=bus, default off)
# -----------------------------------------------------------------------------
# The bus is the pipe, not the plumbing. Postgres stays the system of record;
# Dragonfly keeps presence TTL, rate limits, NIP-98 replay and the fenced
# generation. zenohd routes events and stores nothing — no storage-manager
# plugin, no REST plugin, no dynamic plugin loading, no multicast scouting and
# no gossip scouting. The full contract lives in
# deploy/compose/zenoh/zenohd.json5 and is asserted by `just compose-check`.
#
# MERIDIAN_BUS and MERIDIAN_ZENOH_* are documented once, in the `Cross-pod
# event bus` block near the top of this file. That block owns the relay side;
# this one owns the router and the host port it is published on.
#
# WHAT THIS PROFILE IS FOR: one host. The relay refuses any non-loopback
# endpoint while the link profile cannot rise above the lane floors, so
# `tcp/localhost:<port>` against a router on this machine is the whole of what
# a Zenoh mode can reach today. A multi-pod endpoint (`tcp/zenohd:7447`,
# `tcp/<pod>.<svc>:7447`) is refused by the relay at startup — it is not a
# configuration this repo ships, and the adapter's own tests assert the refusal.
#
# THESE THREE MOVE TOGETHER. ZENOH_HOST_PORT is what Compose publishes;
# MERIDIAN_ZENOH_ENDPOINTS is what the relay dials; docker-compose.override.yml
# is what survives a bare `docker compose up -d`. Move one without the others
# and the relay reports a connect timeout while the container is healthy —
# the REDIS_HOST_PORT/REDIS_URL failure in a new costume. Discovery cannot
# paper over it: multicast and gossip are both off by design, so an endpoint
# the relay was not told about does not exist.
# ZENOH_HOST_PORT=7447
# MERIDIAN_ZENOH_ENDPOINTS=tcp/localhost:7447
#
# Image tag. Pinned in ONE place so the `zenoh` crate and the router image are
# the same release — the charter refuses a 1.8-library/1.9-router pair. Tags
# that exist on Docker Hub: 1.8.0, 1.9.0, 1.10.0 (also 0.11.0 and 1.0.x–1.7.x).
# Never :latest or :nightly, both of which exist and both of which float.
# ZENOH_IMAGE_TAG=1.8.0
# ZENOH_MEMORY_LIMIT=128m
# -----------------------------------------------------------------------------
# Media Upload Admission
# -----------------------------------------------------------------------------

View file

@ -45,6 +45,14 @@
# (os error 61)` from the relay with everything else looking healthy.
# REDIS_HOST_PORT=6390
# REDIS_URL=redis://localhost:6390
#
# zenohd (COMPOSE_PROFILES=bus). Same rule, same failure: ZENOH_HOST_PORT is
# what Compose publishes and MERIDIAN_ZENOH_ENDPOINTS is what the relay dials.
# Mirror any relocation in docker-compose.override.yml with `ports: !override`.
# Neither multicast nor gossip scouting is enabled, so an endpoint the relay
# was not explicitly given is unreachable — there is no discovery fallback.
# ZENOH_HOST_PORT=7448
# MERIDIAN_ZENOH_ENDPOINTS=tcp/localhost:7448
# -----------------------------------------------------------------------------
# Compose profiles — which optional services start
@ -54,6 +62,7 @@
# tools → adminer (DB browser, :8082)
# auth → keycloak (local OAuth, :8180)
# observability → prometheus (:9090)
# bus → zenohd (event-bus router, :7447 — default off)
# artifacts → reductstore (artifact/blob REST tier, :8383 — local disk)
# artifacts-s3 → reductstore (same tier, MinIO behind it — needs a licence)
#

View file

@ -1730,6 +1730,33 @@ window after the last Zenoh-only pod becomes healthy.
### Phase 5 — Deploy the production `zenohd` topology
**Prerequisite — the relay image must build the adapter, and now does.**
`Dockerfile` and `REMAPPING/cicd/Dockerfile` pass
`--features meridian-relay/zenoh` to both `cargo chef cook` and `cargo build`.
Before that every image this repo could build answered `MERIDIAN_BUS=zenoh` and
`MERIDIAN_BUS=shadow` with `ConfigError::InvalidValue`, so a deploy artifact
naming the variable produced a crash loop rather than a bus (`meridian-nok3`).
Keep the two feature lists identical: cooking without the feature leaves the
~77 extra packages to the source layer, where any edit under `crates/`
recompiles them.
**Item 1 lands in two arms, and only the loopback arm is landable today.** The
loopback arm is the root `docker-compose.yml` `bus` profile,
`docker-compose.override.yml.example`, `.env.example`, `.env.local.example`,
`run.sh` with its `PORT_KEYS` list, `.settings/run-profiles/local.yaml`,
`Justfile`, and `deploy/compose/zenoh/zenohd.json5`; it renders
`tcp/localhost:<port>`, which the relay accepts, and it is what makes a live
probe possible at all. The multi-host arm is `deploy/compose/compose.yml`,
`compose.dev.yml`, `deploy/compose/.env.example`, `deploy/compose/README.md`
and the whole of item 2; it renders `tcp/zenohd:7447` and
`tcp/<pod>.<service>:7447`, which `ZenohBusConfig::validate` refuses at startup
while no lane floor exceeds the authenticated link profile. Confirmed against
the built binary, not only against the unit test: `MERIDIAN_BUS=zenoh
MERIDIAN_ZENOH_ENDPOINTS=tcp/zenohd:7447` exits non-zero naming the endpoint.
The multi-host arm stays held until a mutually authenticated link profile
exists, because shipping it earlier publishes a documented happy path that
cannot boot.
1. Port the opt-in `bus` profile prototype to `deploy/compose/compose.yml`,
`deploy/compose/compose.dev.yml`, `deploy/compose/.env.example`,
`deploy/compose/README.md`, the **root `docker-compose.yml` local dev stack**
@ -1896,8 +1923,10 @@ cargo test -p meridian-core kind_to_lane
cargo test -p meridian-pubsub qos
# Phase 5 — deployment contract
just compose-check
just compose-check # run it with and without COMPOSE_PROFILES=bus
just env-doctor
just launcher-check
./run.sh ports --fresh-ports # must reproduce the saved allocation
# Run the existing tests under deploy/charts/meridian/tests/ through the chart's
# documented test entry point when the Phase 5 slice begins.

View file

@ -25,6 +25,12 @@ compose:
# Opt-in service profiles. Core services (postgres, dragonfly, minio) have no
# profile and always start; do not list them here.
# tools → adminer auth → keycloak observability → prometheus
# bus → zenohd (event-bus router) — deliberately NOT listed below
#
# The relay image now builds the adapter, so `bus` is off by choice rather
# than by inability: MERIDIAN_BUS defaults to `redis`, and a router nothing
# dials is inventory. Its port is still allocated below so enabling it later
# does not move anything.
profiles:
- tools
- observability
@ -52,6 +58,10 @@ ports:
adminer: 8083 # tools profile; upstream 8082 — held by codebase-chat adminer
keycloak: 8182 # auth profile; upstream 8180 — held by codebase-chat Keycloak
prometheus: 9091 # observability; upstream 9090 — held by codebase-chat Prom
# `bus` profile (zenohd), default off. Allocated regardless: a port resolved
# only when someone happens to enable the profile is a port that relocates
# under a running stack the first time they do.
zenoh: 7447 # upstream 7447 — free on this host today
# --- Relay host process ----------------------------------------------------
relay: 3003 # upstream 3000 — 3002 held by the CoCo stack's relay

View file

@ -127,10 +127,47 @@ env-doctor:
# reporting, or the value shown contradicts the service list below.
if [[ -f .env.local ]]; then set -a; . ./.env.local; set +a; fi
echo "COMPOSE_ENV_FILES = ${COMPOSE_ENV_FILES:-<unset>}"
# This recipe exports COMPOSE_ENV_FILES=.env, but .env is gitignored — so
# on a fresh clone Compose fails closed with "couldn't find env file" and
# env-doctor, the tool you reach for BECAUSE the layers are confusing,
# dies before printing a single resolved port. Report what was asked for
# on the line above, then drop the names that do not exist.
_cef_keep=""
if [[ -n "${COMPOSE_ENV_FILES:-}" ]]; then
IFS=',' read -ra _cef_parts <<<"$COMPOSE_ENV_FILES"
for _cef in "${_cef_parts[@]}"; do
[[ -f "$_cef" ]] && _cef_keep="${_cef_keep:+${_cef_keep},}${_cef}"
done
if [[ "$_cef_keep" != "$COMPOSE_ENV_FILES" ]]; then
echo " (using ${_cef_keep:-<none>} — the rest do not exist)"
fi
if [[ -n "$_cef_keep" ]]; then export COMPOSE_ENV_FILES="$_cef_keep"; else unset COMPOSE_ENV_FILES; fi
fi
echo "COMPOSE_PROFILES = ${COMPOSE_PROFILES:-<unset> (core services only)}"
for f in .env .env.local docker-compose.override.yml; do
[[ -f "$f" ]] && echo "present: $f" || echo "absent : $f"
done
# The bus is reported separately from the port table because its failure is
# a MISMATCH, not a missing value: a relay on a Zenoh mode with no endpoint
# list, or an endpoint list pointing at a port Compose is not publishing,
# both look completely healthy from `docker compose ps`. Neither multicast
# nor gossip scouting is enabled, so nothing discovers its way out of it.
echo "--- event bus ---"
bus_mode="${MERIDIAN_BUS:-redis (default)}"
bus_peers="${MERIDIAN_ZENOH_ENDPOINTS:-<none>}"
echo " MERIDIAN_BUS ${bus_mode}"
echo " MERIDIAN_ZENOH_ENDPOINTS ${bus_peers}"
if [[ "${MERIDIAN_BUS:-redis}" != "redis" && "${MERIDIAN_ZENOH_ENDPOINTS:-}" == "" ]]; then
echo " !! MERIDIAN_BUS=${MERIDIAN_BUS} with no endpoints: discovery is off on both"
echo " mechanisms, so the relay has no way to reach a router."
fi
if [[ ",${COMPOSE_PROFILES:-}," == *,bus,* ]]; then
echo " profile 'bus' is ON → zenohd on ${ZENOH_HOST_PORT:-7447}, image eclipse/zenoh:${ZENOH_IMAGE_TAG:-1.8.0}"
[[ "${MERIDIAN_BUS:-redis}" == "redis" ]] && \
echo " note: MERIDIAN_BUS=redis — the router will start with nothing dialing it."
else
echo " profile 'bus' is OFF → no zenohd router in this stack"
fi
echo "--- resolved services and host ports ---"
docker compose config --format json > /tmp/meridian-env-doctor.json
python3 - /tmp/meridian-env-doctor.json <<'PY'

View file

@ -13,7 +13,7 @@ for a kind cluster.
| `charts/meridian/` | The relay chart — `templates/` (deployment, service, ingress, httproute, hpa, pdb, servicemonitor, pvc-git, pairing-relay, bundled quickstart MinIO, secrets, `_helpers.tpl`, `_validate.tpl`, `NOTES.txt`), `values.yaml`, `values.schema.json`, `Chart.yaml`/`Chart.lock`, `examples/`, `ci/`, `tests/` |
| `charts/meridian-push-gateway/` | The APNs push-gateway chart, `values-production.yaml`, `tests/render.sh` |
| `charts/meridian-control-plane/` | The control-plane chart, `values.schema.json` (pins `identityProvider` to `oidc`), `tests/render.sh` |
| `compose/` | `compose.yml` + `compose.dev.yml` + `compose.caddy.yml` + `compose.control-plane.yml` + `compose.dokploy.yml`, `Caddyfile`, `.env.example`, `run.sh`, `README.md` |
| `compose/` | `compose.yml` + `compose.dev.yml` + `compose.caddy.yml` + `compose.control-plane.yml` + `compose.dokploy.yml`, `Caddyfile`, `.env.example`, `run.sh`, `README.md`, `zenoh/zenohd.json5` |
| `local/` | `quickstart-ha-values.yaml`, `build-and-deploy.sh` for local kind clusters |
| `../ct.yaml` | chart-testing config (chart dir, target branch, extra args) |
@ -45,6 +45,19 @@ repo between a chart change and production. Images come from
come from the deployment repo.
- Compose is the self-host path and must keep working standalone — do not make it
depend on cluster-only resources.
- **`compose/zenoh/zenohd.json5` is the one router config, mounted read-only by
every stack that runs a router** — the root `docker-compose.yml` `bus` profile
today, `compose.yml` when the multi-host arm lands. Do not copy it per stack:
two copies of a security-relevant config drift and only one gets read.
`just compose-check` parses it and asserts the Phase 5.3 contract (no REST, no
storage-manager, no plugin loading, no multicast, no gossip, QoS on,
lowlatency off, batching on, shared memory explicitly off).
- **A relay env var is only usable if the image builds the code that reads it.**
`MERIDIAN_BUS=zenoh|shadow` is a hard startup error unless the binary was built
`--features meridian-relay/zenoh`, so `Dockerfile` and
`REMAPPING/cicd/Dockerfile` carry that flag on both `cargo chef cook` and
`cargo build`. A chart or Compose file that renders a variable the image cannot
read is a crash loop, not a feature flag (`meridian-nok3`).
- **The proxy overlays are mutually exclusive and opposite.** `compose.caddy.yml`
adds a Caddy container and terminates TLS itself; `compose.dokploy.yml` assumes
Dokploy's Traefik already exists and only unpublishes ports plus adds routing

View file

@ -0,0 +1,100 @@
// =============================================================================
// zenohd.json5 — the ONE Meridian router configuration
// =============================================================================
// Mounted read-only by every stack that runs a router: the local dev backing
// stack (../../../docker-compose.yml, `bus` profile) and the self-host
// production stack (../compose.yml, `bus` profile). One file, because two
// copies of a security-relevant config drift and only one of them is read.
//
// Charter: .settings/features/feature-zenoh-transport.md, Phase 5.3 —
// "Do not enable REST, storage-manager, dynamic plugins, multicast, or gossip.
// Routers route; Postgres stores; the relay authorizes."
//
// -----------------------------------------------------------------------------
// What zenohd overrides no matter what this file says
// -----------------------------------------------------------------------------
// `zenohd/src/main.rs` runs these two lines unconditionally, after loading the
// config and before opening the session:
//
// config.adminspace.set_enabled(true).unwrap();
// config.plugins_loading.set_enabled(true).unwrap();
//
// So `adminspace.enabled: false` and `plugins_loading.enabled: false` below are
// DOCUMENTATION OF INTENT, not enforcement — both render as `true` in zenohd's
// own "Initial conf" log line. The levers that actually hold are:
//
// * `plugins_loading.search_dirs: []` — nothing is findable by name
// * `plugins: {}` — nothing is required by path
// * `--adminspace-permissions none` on the command line (compose `command:`)
// * never passing `-P/--plugin` or `--plugin-search-dir`
//
// Multicast scouting has the inverse trap: zenohd force-ENABLES it when the
// config leaves it unset, so `--no-multicast-scouting` on the command line is
// what survives a config swap. Gossip scouting has no CLI flag at all — this
// file is the only place it can be closed, which is why the compose services
// mount it rather than running a bare `zenohd`.
//
// Verified against eclipse/zenoh 1.8.0, 1.9.0 and 1.10.0: parses clean, router
// reaches "Zenoh can be reached at: tcp/…:7447", and every key below is echoed
// with the intended value in the resolved config.
// =============================================================================
{
mode: "router",
metadata: { name: "meridian-zenohd" },
// Explicit endpoints only — both ambient discovery mechanisms are off below.
// A router that discovers its own peers is a federation primitive; this
// feature deploys routers INSIDE one Meridian deployment (§ Security).
listen: { endpoints: ["tcp/0.0.0.0:7447"] },
connect: { endpoints: [] },
scouting: {
// Docker multicast between host and container does not work anyway, but
// that is not why this is off: ambient discovery is refused on principle.
multicast: { enabled: false },
// No CLI equivalent. Removing this line silently re-enables gossip.
gossip: { enabled: false },
},
transport: {
unicast: {
// QoS and lowlatency are mutually exclusive in Zenoh: the LowLatency
// transport does not preserve QoS prioritization. Q0–Q7 lanes need QoS,
// so lowlatency stays off. Enabling both is rejected by the library.
lowlatency: false,
qos: { enabled: true },
},
link: {
tx: {
queue: {
// Adaptive batching under back-pressure. This is the mechanism the
// ≥5× gate depends on for small payloads; without it every small
// message pays full per-message framing.
batching: { enabled: true, time_limit: 1 },
},
},
},
// Zenoh 1.x defaults shared memory to ENABLED. Phase 6 is unscheduled and
// gated on a measured crossover plus `ulimit -l unlimited`/CAP_IPC_LOCK,
// so leaving this unset silently activates a phase that has not been
// decided. Explicit false, and Phase 6 flips it behind its own flag.
shared_memory: { enabled: false },
},
// Routers stamp an HLC timestamp on data that arrives without one. Peers and
// clients do not — the relay is the authority for event time.
timestamping: { enabled: { router: true, peer: false, client: false } },
// Overridden to `true` by zenohd (see header). `--adminspace-permissions
// none` in the compose `command:` is what closes it; these values keep the
// file honest about the intent and hold if the binary ever stops forcing it.
adminspace: { enabled: false, permissions: { read: false, write: false } },
// No storage-manager, no REST, no anything. Loom's reference config
// (.settings/reference-code/Loom/deployment/compose/zenoh/zenoh.json5)
// enables a RocksDB storage_manager over `loom/**` and a REST admin API on
// :8000. Both are refused here: a router that stores is a second durable log
// (DIAGRAM.md § Scaling axes R2), and Postgres is the system of record.
plugins: {},
plugins_loading: { enabled: false, search_dirs: [] },
}

View file

@ -44,4 +44,13 @@ services:
# - "--proactor_threads=8"
# - "--dbfilename="
# --- Relocate zenohd's host port (COMPOSE_PROFILES=bus) ---------------------
# Only the host side moves; the container still listens on 7447, so the
# in-network `tcp/zenohd:7447` endpoint keeps working for other containers.
# Mirror this in .env.local as ZENOH_HOST_PORT + MERIDIAN_ZENOH_ENDPOINTS.
# There is no REST port to relocate — the plugin is refused.
# zenohd:
# ports: !override
# - "7448:7447"
{}

View file

@ -31,10 +31,17 @@ name: meridian
# tools → adminer (DB browser, :8082)
# auth → keycloak (local OAuth, :8180)
# observability → prometheus (:9090)
# bus → zenohd (event-bus router, :7447 — default OFF)
# artifacts → reductstore (artifact/blob REST tier, :8383 — local disk)
# artifacts-s3 → reductstore (same tier, MinIO behind it — needs a licence)
#
# COMPOSE_PROFILES=tools,auth,observability docker compose up -d
# COMPOSE_PROFILES=bus docker compose up -d # router too; core still starts
#
# `bus` is off in .env.example on purpose. The relay image builds the Zenoh
# adapter, so the router is reachable by a binary that exists — but MERIDIAN_BUS
# defaults to `redis`, and a router nothing dials is inventory, not readiness.
# Turning the profile on is one half; naming a Zenoh mode is the other.
#
# `artifacts` and `artifacts-s3` are mutually exclusive — they bind the same
# host port and are two backings of one tier, not two tiers. Pick one.
@ -452,6 +459,66 @@ services:
com.meridian.env: "dev"
restart: unless-stopped
# ── zenohd — opt-in event-bus router (COMPOSE_PROFILES=bus, default OFF) ───
# Router-mediated topology for the Zenoh transport track. It is the BUS, not
# a second durable log: Postgres stays the system of record, Dragonfly keeps
# presence TTL, rate limits, NIP-98 replay and the fenced generation. Routers
# route; Postgres stores; the relay authorizes.
#
# Default off, and it must stay that way: `just _ensure-services` blocks on
# postgres/dragonfly health for 120s, so a core service behind a profile —
# or this one added to that gate — hangs every dev command.
#
# `command:` carries three settings the config file cannot enforce, because
# zenohd overrides them after loading it (see deploy/compose/zenoh/zenohd.json5):
# --no-multicast-scouting config value is force-ENABLED when unset
# --adminspace-permissions `adminspace.enabled` is force-set to true
# (never -P/--plugin) `plugins_loading.enabled` is force-set to true
# Gossip scouting has no flag at all and is closed only by the mounted file,
# which is why the mount is not optional.
zenohd:
# Tag is parameterized so the Rust crate pin and the router image are set
# to the same release in ONE place. The default must stay a semver literal:
# an unset variable resolving to :latest is how a router silently changes
# protocol version under a pinned client.
image: eclipse/zenoh:${ZENOH_IMAGE_TAG:-1.8.0}
container_name: meridian-zenohd
# Opt-in: COMPOSE_PROFILES=bus (see .env.local.example).
profiles: ["bus"]
init: true
command:
- "--config"
- "/etc/zenoh/zenohd.json5"
- "--no-multicast-scouting"
- "--adminspace-permissions"
- "none"
volumes:
- ./deploy/compose/zenoh/zenohd.json5:/etc/zenoh/zenohd.json5:ro
ports:
# Host port is overridable like every other one here; 7447 is Zenoh's
# well-known port and collides with any other Zenoh stack on the host.
# Relocating it means moving MERIDIAN_ZENOH_ENDPOINTS with it.
- "${ZENOH_HOST_PORT:-7447}:7447"
networks:
- meridian-net
healthcheck:
# TCP 7447 only. The image is Alpine, so this is busybox nc (verified
# `nc -z` works), not reductstore's bash /dev/tcp. Never probe :8000 —
# the REST plugin is refused, so a REST probe would fail a healthy router.
test: ["CMD-SHELL", "nc -z 127.0.0.1 7447"]
interval: 10s
timeout: 3s
retries: 5
start_period: 5s
deploy:
resources:
limits:
memory: ${ZENOH_MEMORY_LIMIT:-128m}
labels:
com.meridian.service: "zenohd"
com.meridian.env: "dev"
restart: unless-stopped
volumes:
postgres-data:
name: meridian-postgres-data

21
run.sh
View file

@ -46,7 +46,7 @@ BLOCK_BEGIN="# >>> meridian run.sh managed block >>>"
BLOCK_END="# <<< meridian run.sh managed block <<<"
# Logical port name -> environment variable it is published as.
PORT_KEYS="postgres redis minio minio_console adminer keycloak prometheus relay health metrics control_plane control_plane_health desktop_vite desktop_hmr web"
PORT_KEYS="postgres redis minio minio_console adminer keycloak prometheus zenoh relay health metrics control_plane control_plane_health desktop_vite desktop_hmr web"
# ── Options ──────────────────────────────────────────────────────────────────
PROFILE="${MERIDIAN_RUN_PROFILE:-local}"
@ -291,6 +291,7 @@ var_for_port() {
adminer) printf 'MERIDIAN_ADMINER_PORT' ;;
keycloak) printf 'MERIDIAN_KEYCLOAK_PORT' ;;
prometheus) printf 'MERIDIAN_PROMETHEUS_PORT' ;;
zenoh) printf 'ZENOH_HOST_PORT' ;;
relay) printf 'MERIDIAN_RELAY_PORT' ;;
health) printf 'MERIDIAN_HEALTH_PORT' ;;
metrics) printf 'MERIDIAN_METRICS_PORT' ;;
@ -584,6 +585,10 @@ MERIDIAN_MINIO_CONSOLE_PORT=${PORT_MINIO_CONSOLE}
MERIDIAN_ADMINER_PORT=${PORT_ADMINER}
MERIDIAN_KEYCLOAK_PORT=${PORT_KEYCLOAK}
MERIDIAN_PROMETHEUS_PORT=${PORT_PROMETHEUS}
# \`bus\` profile only. Allocated whether or not the profile is on, because a
# port that is only resolved when a profile happens to be enabled is a port
# that moves under you the first time someone enables it.
ZENOH_HOST_PORT=${PORT_ZENOH}
# --- Connection strings, paired with the ports above -------------------------
# Moving a port without its URL yields "Connection refused" from the relay with
@ -591,6 +596,11 @@ MERIDIAN_PROMETHEUS_PORT=${PORT_PROMETHEUS}
DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@localhost:${PORT_POSTGRES}/${DB_NAME}
REDIS_URL=redis://localhost:${PORT_REDIS}
MERIDIAN_S3_ENDPOINT=http://localhost:${PORT_MINIO}
# The bus has no discovery fallback — multicast and gossip scouting are both
# off in the router config — so this endpoint list is the ONLY way a relay
# finds the router. It is written unconditionally for the same reason
# REDIS_URL is: the pair must never be half-applied.
MERIDIAN_ZENOH_ENDPOINTS=tcp/localhost:${PORT_ZENOH}
# libpq variables for the psql-based scripts. Defaults resolve to localhost:5432,
# which on a busy host is another project's database — the seed then reports
@ -696,6 +706,13 @@ services:
prometheus:
ports: !override
- "${PORT_PROMETHEUS}:9090"
# \`bus\` profile only. Compose accepts an override for a profiled service and
# simply filters it out when the profile is off, so this block costs nothing
# until COMPOSE_PROFILES includes \`bus\`.
zenohd:
ports: !override
- "${PORT_ZENOH}:7447"
EOF
dbg "wrote docker-compose.override.yml"
}
@ -859,7 +876,7 @@ cmd_ports() {
cmd_env() {
prepare >/dev/null
local v
for v in MERIDIAN_RUN_PROFILE MERIDIAN_PG_HOST_PORT REDIS_HOST_PORT \
for v in MERIDIAN_RUN_PROFILE MERIDIAN_PG_HOST_PORT REDIS_HOST_PORT ZENOH_HOST_PORT \
MERIDIAN_MINIO_PORT MERIDIAN_MINIO_CONSOLE_PORT MERIDIAN_ADMINER_PORT \
MERIDIAN_KEYCLOAK_PORT MERIDIAN_PROMETHEUS_PORT MERIDIAN_RELAY_PORT \
MERIDIAN_HEALTH_PORT MERIDIAN_METRICS_PORT MERIDIAN_BIND_ADDR \