M8-R07: integrate WarmteLink device access into Compose

This commit is contained in:
2026-08-24 06:45:31 +02:00
parent 09abe05f66
commit c851bad829
7 changed files with 55 additions and 45 deletions
+5
View File
@@ -7,6 +7,11 @@ APP_DATABASE_URL=sqlite:////app/data/app.db
AUTH_BOOTSTRAP_USERNAME=admin AUTH_BOOTSTRAP_USERNAME=admin
AUTH_BOOTSTRAP_PASSWORD=change-me AUTH_BOOTSTRAP_PASSWORD=change-me
# Required by Docker Compose for the WarmteLink serial device. Set these only in
# your local .env; use a stable /dev/serial/by-id path and its numeric host GID.
# WARMTELINK_DEVICE_PATH=/dev/serial/by-id/<stable-by-id-name>
# WARMTELINK_SERIAL_GID=<host-serial-gid>
# Optional: runtime overrides. # Optional: runtime overrides.
# Leave these commented out to use the application's built-in defaults. # Leave these commented out to use the application's built-in defaults.
# APP_DEBUG= # APP_DEBUG=
+14
View File
@@ -555,6 +555,20 @@ python scripts/export_openapi.py
- `docker-compose.dev.yml`:本地开发显式叠加层——追加 `build: .`、独立 project / - `docker-compose.dev.yml`:本地开发显式叠加层——追加 `build: .`、独立 project /
容器名(`-dev` 后缀)、暴露 8001,并把 DB 指向挂载的 `./data` 副本,可与生产栈在同一台机器上并存 容器名(`-dev` 后缀)、暴露 8001,并把 DB 指向挂载的 `./data` 副本,可与生产栈在同一台机器上并存
WarmteLink serial access is configured directly by both Compose combinations. Before starting either
one, set these host-specific values in your uncommitted local `.env` (use a stable `/dev/serial/by-id/...`
path, never a transient `/dev/ttyUSB*` name):
```dotenv
WARMTELINK_DEVICE_PATH=/dev/serial/by-id/<stable-by-id-name>
WARMTELINK_SERIAL_GID=<host-serial-gid>
```
Only `app` receives the device as `/dev/warmtelink:rw` and the serial group; `migration` does not.
The app remains non-root, non-privileged, and has no added capabilities. One physical serial port may
have only one owner: stop the app before running the Pre-M8 P1 probe. Never remove `./data`, databases,
or volumes while changing this configuration.
本地开发启动方式(显式叠加 dev 层): 本地开发启动方式(显式叠加 dev 层):
```bash ```bash
+4
View File
@@ -26,3 +26,7 @@ services:
- "127.0.0.1:8002:8000" - "127.0.0.1:8002:8000"
environment: environment:
APP_DATABASE_URL: "sqlite:////app/data/app.db" APP_DATABASE_URL: "sqlite:////app/data/app.db"
devices: !override
- "${WARMTELINK_DEVICE_PATH:?Set a stable /dev/serial/by-id path}:/dev/warmtelink:rw"
group_add: !override
- "${WARMTELINK_SERIAL_GID:?Set the host serial device GID}"
-17
View File
@@ -1,17 +0,0 @@
# Optional WarmteLink serial overlay. Use only together with docker-compose.yml:
# WARMTELINK_DEVICE_PATH=/dev/serial/by-id/<stable-by-id-name> \
# WARMTELINK_SERIAL_GID=<host-serial-gid> \
# docker compose -f docker-compose.yml -f docker-compose.warmtelink.yml up -d
#
# Keep the stable /dev/serial/by-id path on the host: /dev/ttyUSB* names may
# change after a reboot. pyserial opens POSIX serial devices with O_RDWR, so
# Docker must grant the device cgroup rule rw (never m). This is only the
# system permission required to open the fd: the WarmteLink worker itself only
# calls read/close and has no write path. The base compose non-root user stays
# in effect; this overlay grants neither root nor privileged mode.
services:
app:
devices:
- "${WARMTELINK_DEVICE_PATH:?Set a stable /dev/serial/by-id path}:/dev/warmtelink:rw"
group_add:
- "${WARMTELINK_SERIAL_GID:?Set the host serial device GID}"
+4 -1
View File
@@ -22,8 +22,11 @@ services:
condition: service_completed_successfully condition: service_completed_successfully
ports: ports:
- "127.0.0.1:8881:8000" - "127.0.0.1:8881:8000"
devices:
- "${WARMTELINK_DEVICE_PATH:?Set a stable /dev/serial/by-id path}:/dev/warmtelink:rw"
group_add:
- "${WARMTELINK_SERIAL_GID:?Set the host serial device GID}"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./.env:/app/.env:ro - ./.env:/app/.env:ro
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
+15 -15
View File
@@ -4,12 +4,12 @@
## 安全边界与开始前备份 ## 安全边界与开始前备份
- 应用容器继续使用基础 `docker-compose.yml` 中的非 root `user: "1000:1000"`overlay 不设置 `user``privileged` 或额外 capability。 - 应用容器继续使用基础 `docker-compose.yml` 中的非 root `user: "1000:1000"`基础文件和 dev 合并配置都不设置 `privileged` 或额外 capability。
- Docker device cgroup 必须以 `rw` 映射,容器内固定为 `/dev/warmtelink`:这是 pyserial 3.5 在 POSIX 上以 `O_RDWR` 打开串口所需的最小系统权限,并不表示业务可写。规则绝不包含 `m`overlay 也不授予 root、`privileged` 或额外 capability。WarmteLink worker 仍只调用 serial `read` / `close`,绝不调用 `write` 或发送写命令。 - Docker device cgroup 必须以 `rw` 映射,容器内固定为 `/dev/warmtelink`:这是 pyserial 3.5 在 POSIX 上以 `O_RDWR` 打开串口所需的最小系统权限,并不表示业务可写。规则绝不包含 `m`不授予 root、`privileged` 或额外 capability。WarmteLink worker 仍只调用 serial `read` / `close`,绝不调用 `write` 或发送写命令。
- 不删除或覆盖 `app_config``app.db`、旧数据库、Docker volume 或既有 source。禁用/解绑/回滚配置不是删除历史的替代方式。 - 不删除或覆盖 `app_config``app.db`、旧数据库、Docker volume 或既有 source。禁用/解绑/回滚配置不是删除历史的替代方式。
- 维护前停止写入窗口,使用宿主机的备份流程复制 `./data/app.db` 到受保护的备份位置;确认备份可用后才运行 migration。不要把生产库复制到开发机或用于测试。 - 维护前停止写入窗口,使用宿主机的备份流程复制 `./data/app.db` 到受保护的备份位置;确认备份可用后才运行 migration。不要把生产库复制到开发机或用于测试。
## 识别稳定串口并启用 overlay ## 识别稳定串口并配置 Compose
在宿主机(不是容器)找出稳定 symlink;不要使用会在重启后变化的 `/dev/ttyUSB*` 名称: 在宿主机(不是容器)找出稳定 symlink;不要使用会在重启后变化的 `/dev/ttyUSB*` 名称:
@@ -19,29 +19,30 @@ stable_path=/dev/serial/by-id/<stable-by-id-name>
stat -c '%g %n' "$stable_path" stat -c '%g %n' "$stable_path"
``` ```
记录输出的数字 GID,而不是猜测 `dialout` 的数值。确认 path 指向预期的字符设备后,临时导出变量 记录输出的数字 GID,而不是猜测 `dialout` 的数值。确认 path 指向预期的字符设备后,在部署机本地 `.env`(不提交)设置
```bash ```bash
export WARMTELINK_DEVICE_PATH=/dev/serial/by-id/<stable-by-id-name> WARMTELINK_DEVICE_PATH=/dev/serial/by-id/<stable-by-id-name>
export WARMTELINK_SERIAL_GID=<host-serial-gid> WARMTELINK_SERIAL_GID=<host-serial-gid>
docker compose -f docker-compose.yml -f docker-compose.warmtelink.yml config
docker compose -f docker-compose.yml -f docker-compose.warmtelink.yml up -d
``` ```
`config``app` 必须仍显示 `user: "1000:1000"`,并只出现从 stable by-id path 到 `/dev/warmtelink``rw` device mapping 与 `group_add` GID;不得出现 `privileged`、root user 或 `m` device permission。`rw` 仅满足 pyserial 的 POSIX `O_RDWR` 打开,不改变 worker 的只读业务行为。默认部署不带第二个 `-f` production 使用基础 composelocal dev 使用 base 与 dev 合并文件。两种环境都会从本地 `.env` 读取这两个变量
```bash ```bash
docker compose -f docker-compose.yml up -d docker compose -f docker-compose.yml up -d
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
``` ```
因此无串口硬件时默认 stack 不会声明不存在的 device。不得把设备路径或 GID 写回 `docker-compose.warmtelink.yml``.env.example` 或文档中的真实值 启动前可用相同文件组合运行 `docker compose ... config`。结果的 `app` 必须仍显示 `user: "1000:1000"`,并只出现从 stable by-id path 到 `/dev/warmtelink``rw` device mapping 与 `group_add` GIDmigration 不得有 device 或 group。不得出现 `privileged`、root user 或 `m` device permission。`rw` 仅满足 pyserial 的 POSIX `O_RDWR` 打开,不改变 worker 的只读业务行为。不得把设备路径或 GID 写入仓库的 `.env.example` 或文档。
同一物理串口在任意时刻只能有一个 owner。运行 Pre-M8 `p1_probe.py` 前,必须先停止 app(包括 dev stack),并在 probe 结束后再启动 app;不要让 probe 与 worker 同时打开该串口。
## Migration 与 source 配置 ## Migration 与 source 配置
先在维护窗口运行 migration;它只升级 schema,绝不删除历史表或配置: 先在维护窗口运行 migration;它只升级 schema,绝不删除历史表或配置:
```bash ```bash
docker compose -f docker-compose.yml -f docker-compose.warmtelink.yml run --rm migration docker compose -f docker-compose.yml run --rm migration
``` ```
登录 Energy 页面,在 **Sources** 创建 `warmtelink_serial` source 登录 Energy 页面,在 **Sources** 创建 `warmtelink_serial` source
@@ -81,14 +82,13 @@ docker compose -f docker-compose.yml -f docker-compose.warmtelink.yml run --rm m
## 安全回滚 ## 安全回滚
1. 在 UI 禁用 WarmteLink source,确认状态离线且 worker 已停止;保留 channels、bindings、history、contracts 与成本账本。 1. 在 UI 禁用 WarmteLink source,确认状态离线且 worker 已停止;保留 channels、bindings、history、contracts 与成本账本。
2. 停止 overlay stack 后,以默认 compose 启动:`docker compose -f docker-compose.yml up -d`。这会移除容器内 device 映射,但不会删除 `./data`、数据库、配置或 volumes 2. 停止 app 后,在 UI 保持 source 禁用;这不会删除 `./data`、数据库、配置或 volumes。需要恢复 WarmteLink 时,确认本地 `.env` 的 stable by-id/GID 后再重新启用 source
3. 确认 DSMR、Modbus、电价、既有 electricity 成本和前端正常。若需要恢复 WarmteLink,按上面的 stable by-id/GID 步骤再次启用 overlay 和 source。 3. 确认 DSMR、Modbus、电价、既有 electricity 成本和前端正常。
4. schema migration 不应以 production downgrade 回滚;只有经过验证的备份恢复流程才处理灾难恢复,且必须由 operator 在隔离维护窗口执行。 4. schema migration 不应以 production downgrade 回滚;只有经过验证的备份恢复流程才处理灾难恢复,且必须由 operator 在隔离维护窗口执行。
## 上线验收清单 ## 上线验收清单
- 默认 compose 在无 serial 硬件时正常启动 - production 与 base+dev Compose 都从本地 `.env` 获取 serial path/GIDapp/migration 均为非 root,只有 app 有 `/dev/warmtelink:rw` 和 serial GID,绝无 `m`、root 或 privileged。该 `rw` 仅为 pyserial 的 `O_RDWR` 打开,worker 业务仍只读,且没有真实设备/GID 被记录在仓库
- overlay 合并后 app/migration 均为非 root;只有 app 有 `/dev/warmtelink:rw` 和 serial GID,绝无 `m`、root 或 privileged。该 `rw` 仅为 pyserial 的 `O_RDWR` 打开,worker 业务仍只读,且没有真实设备/GID 被记录在仓库。
- 下列项目是交付后由用户在备份数据库、可回滚部署、真实 serial 设备和真实 HA 环境执行的人工验收;自动化技术验收不能替代这些观察,也不得把它们伪称为已执行。 - 下列项目是交付后由用户在备份数据库、可回滚部署、真实 serial 设备和真实 HA 环境执行的人工验收;自动化技术验收不能替代这些观察,也不得把它们伪称为已执行。
### 用户人工验收记录(交付后填写) ### 用户人工验收记录(交付后填写)
+13 -12
View File
@@ -72,35 +72,36 @@ def test_compose_uses_migration_job_before_app() -> None:
assert dev["services"]["app"]["build"] == "." assert dev["services"]["app"]["build"] == "."
def test_warmtelink_overlay_keeps_app_non_root_and_maps_minimal_serial_device_access() -> None: def test_compose_keeps_app_non_root_and_maps_minimal_warmtelink_serial_access() -> None:
"""The optional overlay grants only pyserial's minimal open permission. """Base Compose maps the configured device with only pyserial's required access.
Environment interpolation is deliberately left unresolved: operators supply Environment interpolation is deliberately left unresolved: operators supply
a host-specific stable by-id path and its numeric serial GID at deployment. a host-specific stable by-id path and its numeric serial GID in local .env.
The dev compose file inherits this app configuration unchanged.
""" """
base = _read_yaml("docker-compose.yml") base = _read_yaml("docker-compose.yml")
overlay = _read_yaml("docker-compose.warmtelink.yml") dev = _read_yaml("docker-compose.dev.yml")
base_app = base["services"]["app"] base_app = base["services"]["app"]
base_migration = base["services"]["migration"] base_migration = base["services"]["migration"]
app_overlay = overlay["services"]["app"]
assert base_app["user"] == "1000:1000" assert base_app["user"] == "1000:1000"
assert base_migration["user"] == "1000:1000" assert base_migration["user"] == "1000:1000"
assert "privileged" not in base_app assert "privileged" not in base_app
assert "privileged" not in base_migration assert "privileged" not in base_migration
assert "user" not in app_overlay assert "cap_add" not in base_app
assert "privileged" not in app_overlay assert "cap_add" not in base_migration
assert app_overlay["devices"] == [ assert base_app["devices"] == [
"${WARMTELINK_DEVICE_PATH:?Set a stable /dev/serial/by-id path}:/dev/warmtelink:rw" "${WARMTELINK_DEVICE_PATH:?Set a stable /dev/serial/by-id path}:/dev/warmtelink:rw"
] ]
device_rule = app_overlay["devices"][0] device_rule = base_app["devices"][0]
permissions = device_rule.rsplit(":", maxsplit=1)[1] permissions = device_rule.rsplit(":", maxsplit=1)[1]
assert permissions == "rw" assert permissions == "rw"
assert "m" not in permissions assert "m" not in permissions
assert app_overlay["group_add"] == ["${WARMTELINK_SERIAL_GID:?Set the host serial device GID}"] assert base_app["group_add"] == ["${WARMTELINK_SERIAL_GID:?Set the host serial device GID}"]
assert "/dev/serial/by-id" in (PROJECT_ROOT / "docker-compose.warmtelink.yml").read_text() assert dev["services"]["app"]["devices"] == base_app["devices"]
assert "migration" not in overlay["services"] assert dev["services"]["app"]["group_add"] == base_app["group_add"]
assert "devices" not in base_migration assert "devices" not in base_migration
assert "group_add" not in base_migration
assert "privileged" not in base_migration assert "privileged" not in base_migration