diff --git a/.env.example b/.env.example index 1115f4b..22c86e7 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,11 @@ APP_DATABASE_URL=sqlite:////app/data/app.db AUTH_BOOTSTRAP_USERNAME=admin 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/ +# WARMTELINK_SERIAL_GID= + # Optional: runtime overrides. # Leave these commented out to use the application's built-in defaults. # APP_DEBUG= diff --git a/README.md b/README.md index b92e6e0..2a4d399 100644 --- a/README.md +++ b/README.md @@ -555,6 +555,20 @@ python scripts/export_openapi.py - `docker-compose.dev.yml`:本地开发显式叠加层——追加 `build: .`、独立 project / 容器名(`-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/ +WARMTELINK_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 层): ```bash diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index edab84f..dac1fcf 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -26,3 +26,7 @@ services: - "127.0.0.1:8002:8000" environment: 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}" diff --git a/docker-compose.warmtelink.yml b/docker-compose.warmtelink.yml deleted file mode 100644 index a8d100a..0000000 --- a/docker-compose.warmtelink.yml +++ /dev/null @@ -1,17 +0,0 @@ -# Optional WarmteLink serial overlay. Use only together with docker-compose.yml: -# WARMTELINK_DEVICE_PATH=/dev/serial/by-id/ \ -# WARMTELINK_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}" diff --git a/docker-compose.yml b/docker-compose.yml index fad1c8b..7019365 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,8 +22,11 @@ services: condition: service_completed_successfully ports: - "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: - ./data:/app/data - ./.env:/app/.env:ro - /etc/localtime:/etc/localtime:ro - diff --git a/docs/warmtelink-energy.md b/docs/warmtelink-energy.md index eb0ea73..0c0534a 100644 --- a/docs/warmtelink-energy.md +++ b/docs/warmtelink-energy.md @@ -4,12 +4,12 @@ ## 安全边界与开始前备份 -- 应用容器继续使用基础 `docker-compose.yml` 中的非 root `user: "1000:1000"`;overlay 不设置 `user`、`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-compose.yml` 中的非 root `user: "1000:1000"`;基础文件和 dev 合并配置都不设置 `privileged` 或额外 capability。 +- 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。禁用/解绑/回滚配置不是删除历史的替代方式。 - 维护前停止写入窗口,使用宿主机的备份流程复制 `./data/app.db` 到受保护的备份位置;确认备份可用后才运行 migration。不要把生产库复制到开发机或用于测试。 -## 识别稳定串口并启用 overlay +## 识别稳定串口并配置 Compose 在宿主机(不是容器)找出稳定 symlink;不要使用会在重启后变化的 `/dev/ttyUSB*` 名称: @@ -19,29 +19,30 @@ stable_path=/dev/serial/by-id/ stat -c '%g %n' "$stable_path" ``` -记录输出的数字 GID,而不是猜测 `dialout` 的数值。确认 path 指向预期的字符设备后,临时导出变量: +记录输出的数字 GID,而不是猜测 `dialout` 的数值。确认 path 指向预期的字符设备后,在部署机本地 `.env`(不提交)设置: ```bash -export WARMTELINK_DEVICE_PATH=/dev/serial/by-id/ -export WARMTELINK_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 +WARMTELINK_DEVICE_PATH=/dev/serial/by-id/ +WARMTELINK_SERIAL_GID= ``` -`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 使用基础 compose;local dev 使用 base 与 dev 合并文件。两种环境都会从本地 `.env` 读取这两个变量: ```bash 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` GID;migration 不得有 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;它只升级 schema,绝不删除历史表或配置: ```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: @@ -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 与成本账本。 -2. 停止 overlay stack 后,以默认 compose 启动:`docker compose -f docker-compose.yml up -d`。这会移除容器内 device 映射,但不会删除 `./data`、数据库、配置或 volumes。 -3. 确认 DSMR、Modbus、电价、既有 electricity 成本和前端正常。若需要恢复 WarmteLink,按上面的 stable by-id/GID 步骤再次启用 overlay 和 source。 +2. 停止 app 后,在 UI 保持 source 禁用;这不会删除 `./data`、数据库、配置或 volumes。需要恢复 WarmteLink 时,确认本地 `.env` 的 stable by-id/GID 后再重新启用 source。 +3. 确认 DSMR、Modbus、电价、既有 electricity 成本和前端正常。 4. schema migration 不应以 production downgrade 回滚;只有经过验证的备份恢复流程才处理灾难恢复,且必须由 operator 在隔离维护窗口执行。 ## 上线验收清单 -- 默认 compose 在无 serial 硬件时正常启动。 -- overlay 合并后 app/migration 均为非 root;只有 app 有 `/dev/warmtelink:rw` 和 serial GID,绝无 `m`、root 或 privileged。该 `rw` 仅为 pyserial 的 `O_RDWR` 打开,worker 业务仍只读,且没有真实设备/GID 被记录在仓库。 +- production 与 base+dev Compose 都从本地 `.env` 获取 serial path/GID;app/migration 均为非 root,只有 app 有 `/dev/warmtelink:rw` 和 serial GID,绝无 `m`、root 或 privileged。该 `rw` 仅为 pyserial 的 `O_RDWR` 打开,worker 业务仍只读,且没有真实设备/GID 被记录在仓库。 - 下列项目是交付后由用户在备份数据库、可回滚部署、真实 serial 设备和真实 HA 环境执行的人工验收;自动化技术验收不能替代这些观察,也不得把它们伪称为已执行。 ### 用户人工验收记录(交付后填写) diff --git a/tests/test_deployment.py b/tests/test_deployment.py index b9a8a02..93e86d5 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -72,35 +72,36 @@ def test_compose_uses_migration_job_before_app() -> None: assert dev["services"]["app"]["build"] == "." -def test_warmtelink_overlay_keeps_app_non_root_and_maps_minimal_serial_device_access() -> None: - """The optional overlay grants only pyserial's minimal open permission. +def test_compose_keeps_app_non_root_and_maps_minimal_warmtelink_serial_access() -> None: + """Base Compose maps the configured device with only pyserial's required access. 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") - overlay = _read_yaml("docker-compose.warmtelink.yml") + dev = _read_yaml("docker-compose.dev.yml") base_app = base["services"]["app"] base_migration = base["services"]["migration"] - app_overlay = overlay["services"]["app"] assert base_app["user"] == "1000:1000" assert base_migration["user"] == "1000:1000" assert "privileged" not in base_app assert "privileged" not in base_migration - assert "user" not in app_overlay - assert "privileged" not in app_overlay - assert app_overlay["devices"] == [ + assert "cap_add" not in base_app + assert "cap_add" not in base_migration + assert base_app["devices"] == [ "${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] assert permissions == "rw" assert "m" not in permissions - assert app_overlay["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 "migration" not in overlay["services"] + assert base_app["group_add"] == ["${WARMTELINK_SERIAL_GID:?Set the host serial device GID}"] + assert dev["services"]["app"]["devices"] == base_app["devices"] + assert dev["services"]["app"]["group_add"] == base_app["group_add"] assert "devices" not in base_migration + assert "group_add" not in base_migration assert "privileged" not in base_migration