Changelog¶
All notable changes to this project are documented here.
The format follows Keep a Changelog, and this project adheres to Semantic Versioning. Written in English like the rest of the documentation (ADR 0002), even though commit messages are German - a changelog addresses the same readers the documentation does. Every entry names its issue, so the reasoning behind a change is one click away instead of restated here.
[Unreleased]¶
Added¶
- BridgeLink now reports application metrics to Prometheus (#60). Until now the only BridgeLink data Prometheus held came from cAdvisor - CPU, RAM and network of the container. Those cannot distinguish a busy engine from a stalled one: a channel that is deployed but stopped, or a destination queue that has stopped draining, looks exactly like an idle healthy container. For an HL7/FHIR integration engine that is the failure mode that matters most, because it is silent.
BridgeLink serves no /metrics endpoint of its own, so the bridgelink role gained a
small exporter sidecar that queries the engine's REST API: per-channel and per-connector
state, message counters by outcome, queue depth, JVM heap. Six alert rules ship with it
in the monitoring role, dormant until the metrics appear (the same pattern as the backup
rules).
Opt-in, and it stays that way: the exporter authenticates against BridgeLink's own
user database, which this kit does not manage - that user only exists after the operator
logs into the Administrator for the first time. Set bridgelink_exporter_enabled and
monitoring_scrape_bridgelink together once it does; see docs/roles/bridgelink.md.
Prometheus reaches the exporter by container name over a shared Docker network
(linumed-base-metrics, created by the monitoring role), not over a host port. No port
is published for it beyond a loopback one for manual debugging. The Node Exporter job's
host.docker.internal arrangement deliberately was not copied: it works only because
Node Exporter is a native service ufw can protect, and a published container port would
bypass the firewall (#64). This does mean the monitoring role has to run before the
bridgelink role on a host - site.yml already orders them that way.
prometheus-community/json_exporter was tested first and rejected: BridgeLink's JSON is
serialised from its XML model, so a single-channel installation renders a list as an
object and json_exporter's JSONPath silently matches nothing while still reporting the
scrape as successful. The exporter reads the XML representation instead, which has no
such ambiguity.
vm-testnow exercises the BridgeLink exporter with the switch on (#62). The existing double-run appliessite.ymlwith role defaults, and the exporter defaults to off - so it proved the feature is a no-op while disabled, and nothing more. The enable path had never run through Ansible at all. A third pass now creates the BridgeLink user over the REST API (a throwaway VM has no operator to do it), re-applies with the exporter on, and checks the secret's ownership, the container healthcheck,bridgelink_up, and that Prometheus's newbridgelinkjob is healthy.
Worth generalising: anything added behind a default-off flag ships with zero coverage unless a test deliberately turns it on.
- Architecture diagrams are pre-rendered SVGs and were redrawn (#65). The Mermaid
diagrams in
ARCHITECTURE.mdwere rendered by JavaScript in the reader's browser, which meant the site's theme reached the MkDocs build and nothing else - the same diagram looked different on GitHub and in Forgejo. They are now committed SVGs underdocs/img/, rendered from sources indocs/diagrams/byscripts/render-diagrams.sh, and identical everywhere. The vendoredmermaid.min.js, its init script and its stylesheet are gone, and with them the class-name workaround that only existed to stop the theme from fetching a renderer off a CDN at runtime.
The target-architecture figure was also redrawn, because no amount of theming was going to fix it: it drew arrows at the boundary of the Docker group, including one from that group to a node inside itself, which renders as a label with no arrow attached. It is now two figures - what runs where, and what reports to whom - and the second says more than the original did, since the metric paths are actually visible.
A trade-off worth stating: a static SVG cannot follow the site's light/dark toggle the way runtime rendering did. The diagrams render as a light card that stays legible on either background, which is the price of looking the same on all three surfaces.
CI re-renders the diagrams on every docs build and fails if the committed SVGs are out of date, so a source edited without re-rendering cannot ship.
Fixed¶
- Prometheus config changes now actually reach Prometheus (#63). Since the monitoring
role was written,
prometheus.ymlandalert-rules.ymlwere bind-mounted as individual files. A single-file bind mount binds the inode, and Ansible'stemplatemodule writes a temp file and renames it into place - creating a new one. The container went on reading the orphaned old copy, so every configuration and alert-rule change after the first deploy silently did nothing: the playbook reportedchanged, validation passed, the/-/reloadhandler answered 200, and Prometheus kept running the config from day one. Nothing reported an error anywhere.
Both files now live in {{ monitoring_deploy_dir }}/prometheus/ and that directory is
mounted, which restores the intended behaviour while keeping the zero-downtime reload.
The role also removes the two files from their old top-level location, so the next person
debugging a config that "does not apply" cannot find and edit the copy that no longer
matters.
Only Prometheus was affected. Loki, Alloy and Alertmanager are updated by restarting their container, and a restart re-resolves the bind mount - it is Prometheus's deliberate HTTP reload, chosen for zero downtime, that made it the sole victim. Same failure class as the Caddyfile mount in #44/#48; the precedent existed and had simply never been applied here.
If you deployed an earlier version: any Prometheus config or alert-rule change you made since the first deploy never took effect. The next playbook run applies all of them at once.
- Documented the ownership the BridgeLink secret files actually need (#60). The role's
README and the hand-run reference in
docker/bridgelink/both describedsecrets/mirth.propertiesas root-owned0600. A file-based Docker secret keeps the host's ownership when it is mounted, and the hardened image runs as UID 65532, so following that instruction producesAccessDeniedException: /run/secrets/mirth_propertiesand a restart loop - which, because the image supports no healthcheck, Compose still reports as a successfully started stack. The Ansible role always set the ownership correctly; only the documentation was wrong, and only the manual path was affected.
Changed¶
- The product is now called Linumed Base. It was released as "Linumed OS" through
v0.2.0; the name was wrong, because this is a collection of Ansible roles that configure a standard Debian install, not an operating system, a distribution or a bootable image. Renamed while nothing was published and nothing was installed anywhere - see ADR 0006 for the full reasoning and for what was deliberately not renamed.
What this means for you: identifiers changed with the name. Container names are now
linumed-base-*, the default deploy path is /opt/linumed-base, and the shared Docker
network is linumed-base-external. There are no known installations of the earlier
releases, so no migration path is provided - if you did install v0.1.0 or v0.2.0,
treat this as a fresh deployment rather than an upgrade.
Git history and the v0.1.0 / v0.2.0 tag messages keep the old name on purpose. Those
releases happened under it, and rewriting published history to pretend otherwise would
be dishonest.
[0.2.0] - 2026-08-17¶
Operational maturity rather than new services: the v0.1 role set stayed as it was, and the gaps a repository audit surfaced got closed - real users instead of shared logins, tests that verify behaviour instead of configuration, and a restore test that actually restores.
Added¶
- Tunnel-only SSH users (
common_ssh_tunnel_users) - shell-less accounts restricted bysshditself to exactly the loopback ports listed, so someone who should read a dashboard no longer needs a shell on a machine that processes patient data (#41). - Individual Grafana users (
monitoring_grafana_users) with Viewer/Editor/Admin roles, provisioned through Grafana's HTTP API, replacing the shared admin login as the way everyone views dashboards and logs (#42). - Optional OIDC for Grafana (
monitoring_grafana_oidc_*) pointing at an institution's existing identity provider. No provider is bundled, and empty settings change nothing (ADR 0003, #42). - Shared Docker network for Caddy (
caddy_external_network_name, defaultlinumed-base-external) so Caddy can reverse-proxy a container in an operator's own, separate Compose stack by service name - without publishing a port or touching ufw (#39). - Automated weekly restore test in the
backuprole: restores into a throwaway target, diffs against the live source, and reports the result as its own Prometheus metrics, withRestoreTestFailedandRestoreTestStalealert rules. A restore test that silently stops running is now as visible as one that fails (#36). - VM provisioning and idempotency checks in CI - a full
site.ymldouble-run against a real libvirt/KVM VM, not just linting (ADR 0004, #33). Since #45 it triggers itself on changes underansible/,docker/,test/andscripts/. - Health checks for Alertmanager, cAdvisor, docker-socket-proxy and Alloy. All four
had been assumed impossible; all four turned out to have a working option once actually
tested.
grafana/lokiremains the single genuine exception (#38). - Smoke test for the
docker/references, which previously had no coverage of any kind (#46, #48). SECURITY.md- private reporting channel, realistic response times for a one-person project, and an explicit scope boundary against upstream software (#47).- MkDocs documentation site (Material theme) with a cross-role operations handbook
under
docs/operations/(#26). - Optional Docker Hub pull-through cache (
docker_registry_mirrors) for test runs (#43).
Changed¶
- Documentation language is English (ADR 0002).
ARCHITECTURE.mdand alldocs/roles/*.mdwere translated; ADR 0001 stays German on purpose, as a record of a decision as it was made (#30). docker/<role>/is a manual-testing reference, not a required mirror of the Ansible templates - the templates are the single source of truth (ADR 0005, #37).
Fixed¶
- Caddyfile changes never reached the running container after the first deploy. The
Caddyfile was bind-mounted as a single file, which attaches to that file's inode, while
Ansible's
templatemodule rewrites atomically - so the container kept serving the original file forever, withcaddy reloadreporting success and the health check staying green throughout. Now the containing directory is mounted instead (#44). - The
docker/caddyreference was left broken by that same fix - itsCaddyfile.examplestayed in the old location, so following the directory's own instructions produced a crash-looping Caddy (#48). - The osinfo-db in the CI job container predated Debian 12/13, breaking
virt-installin the VM test workflow (#33).
Breaking¶
- The Caddyfile moved from
{{ caddy_deploy_dir }}/Caddyfileto{{ caddy_deploy_dir }}/conf/Caddyfile(#44). The role deletes the file at the old path on the next run, so no manual migration is needed - but anyone who edited that file directly, or whose own tooling references the old path, has to follow the move. Edits to the old location have no effect.
[0.1.0] - 2026-08-14¶
First tagged release. Complete v0.1 role set, each verified against a real Debian 13 VM.
Added¶
common- SSH hardening (#1), ufw (#3), fail2ban (#10), unattended-upgrades (#5), NTP/timezone (#11), and anssh.socketoverride instead of aborting on socket activation (#15).docker- Docker Engine and the Compose plugin from the official repository, as a shared prerequisite for every Docker-based role (#17).caddy- reverse proxy with automatic ACME/TLS, Caddyfile generated fromcaddy_sites, validated before it takes effect and reloaded without downtime (#6).monitoring- Prometheus, Grafana, Loki, Grafana Alloy, Alertmanager, cAdvisor and a native Node Exporter. Only Grafana and Prometheus get a host port, both on loopback (#8, #9). Optional Alertmanager SMTP delivery, all-or-nothing (#22); Alloy reaches the Docker API only through a read-only socket proxy (#21).bridgelink- HL7 v2 / FHIR R4 integration engine, an MPL-2.0 fork of Mirth Connect, which went proprietary in March 2025 (ADR 0001, #12).backup- restic via systemd timer, any restic backend, with the result exported as Prometheus metrics (#7).scripts/bootstrap.sh- establishes thepython3/sudobaseline on a minimal netinst install, where neither is guaranteed, plus a separate netinst test path (#13, #14, #25).- Complete example inventory with an Ansible Vault template (#27, #28).
Fixed¶
- Node Exporter was DOWN in Prometheus on every fresh install - ufw blocked Prometheus's own scrape, and both VM tests stayed green because neither had ever asked Prometheus whether its targets were reachable (#40).
.gitignorehad excluded the entire example inventory since the initial commit - every clone got an empty directory (#27).- The README quick start ran from the wrong directory, so
ansible.cfgwas never loaded and the first task failed with "role common not found". Every automated test implicitly did the right thing, which is why nobody noticed (#29).