Dedicated hardware poller + read-only consumers #4

Open
adamksmith wants to merge 13 commits from feat/dedicated-poller into main
9 changed files with 137 additions and 90 deletions
Showing only changes of commit cccc2bc004 - Show all commits

View File

@@ -6,3 +6,5 @@ __pycache__
.git .git
.env .env
*.md *.md
tmp/
.venv/

View File

@@ -1,4 +1,4 @@
# ---- Stage 1: Build frontend ---- # ---- Stage: build frontend ----
FROM node:22-alpine AS frontend-build FROM node:22-alpine AS frontend-build
WORKDIR /app/frontend WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json* ./ COPY frontend/package.json frontend/package-lock.json* ./
@@ -7,8 +7,10 @@ COPY frontend/ ./
RUN npm run build RUN npm run build
# Output: /app/static/ # Output: /app/static/
# ---- Stage 2: Production runtime ---- # ---- Target: poller (hardware producer) ----
FROM python:3.13-slim # Privileged at runtime; the only image with SAS/SMART tooling. Slim Python
# deps (just Redis) — no web stack.
FROM python:3.13-slim AS poller
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
smartmontools \ smartmontools \
@@ -20,16 +22,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY requirements-poller.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements-poller.txt
COPY poller.py .
COPY services/ services/
CMD ["python", "-u", "poller.py"]
# ---- Target: web (read-only consumer) ----
# Unprivileged at runtime; no hardware tools. Serves REST/UI + MQTT, reads Redis.
FROM python:3.13-slim AS web
WORKDIR /app
COPY requirements-web.txt .
RUN pip install --no-cache-dir -r requirements-web.txt
COPY main.py . COPY main.py .
COPY poller.py .
COPY routers/ routers/ COPY routers/ routers/
COPY services/ services/ COPY services/ services/
COPY models/ models/ COPY models/ models/
# Copy built frontend from stage 1
COPY --from=frontend-build /app/static/ static/ COPY --from=frontend-build /app/static/ static/
EXPOSE 8000 EXPOSE 8000

View File

@@ -45,12 +45,21 @@ apt install smartmontools sg3-utils ledmon # Debian/Ubuntu
## Run (docker compose) ## Run (docker compose)
Two **independent** stacks so web iterations never recreate the privileged
poller (which touches the SAS bus). They're built as two images —
`jbod-poller` (privileged, has the SAS/SMART tooling) and `jbod-web` (slim,
unprivileged) — and share Redis over host networking.
```bash ```bash
docker compose up -d --build # 1. Infra: poller + redis — the stable substrate. Deploy once; touch deliberately.
docker compose -f compose.infra.yml up -d
# 2. Web: REST/UI/MQTT — iterate freely; this only ever recreates `app`.
docker compose -f compose.web.yml up -d --build
``` ```
Brings up `poller` (privileged), `app` (port 8000), and `redis`. To run the two Pin both images to a known-good SHA in deploy (`./build.sh` tags `:<sha>` and
processes by hand instead: `:latest` for both). To run the two processes by hand instead:
```bash ```bash
REDIS_HOST=localhost sudo -E python poller.py # producer (needs root) REDIS_HOST=localhost sudo -E python poller.py # producer (needs root)

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
IMAGE="docker.adamksmith.xyz/jbod-monitor" REG="docker.adamksmith.xyz"
cd "$(dirname "$0")" cd "$(dirname "$0")"
@@ -10,16 +10,21 @@ git add -A
if git diff --cached --quiet; then if git diff --cached --quiet; then
echo "No changes to commit, using HEAD" echo "No changes to commit, using HEAD"
else else
git commit -m "${1:-Build and push image}" git commit -m "${1:-Build and push images}"
fi fi
SHA=$(git rev-parse --short HEAD) SHA=$(git rev-parse --short HEAD)
echo "Building ${IMAGE}:${SHA}" # Two images from one Dockerfile (shared frontend build stage is cached):
docker build -t "${IMAGE}:${SHA}" -t "${IMAGE}:latest" . # jbod-poller — privileged hardware producer
# jbod-web — unprivileged read-only consumer (REST/UI/MQTT)
for target in poller web; do
img="${REG}/jbod-${target}"
echo "Building ${img}:${SHA}"
docker build --target "${target}" -t "${img}:${SHA}" -t "${img}:latest" .
echo "Pushing ${img}:${SHA}"
docker push "${img}:${SHA}"
docker push "${img}:latest"
done
echo "Pushing ${IMAGE}:${SHA}" echo "Done: ${REG}/jbod-{poller,web}:${SHA}"
docker push "${IMAGE}:${SHA}"
docker push "${IMAGE}:latest"
echo "Done: ${IMAGE}:${SHA}"

47
compose.infra.yml Normal file
View File

@@ -0,0 +1,47 @@
# Infra stack: the hardware poller + Redis. This is the STABLE substrate —
# deploy once, pin to a known-good image SHA, and touch it deliberately
# (never as part of a web iteration). Web redeploys use compose.web.yml and
# can never name these services.
#
# docker compose -f compose.infra.yml up -d # bring up / update poller+redis
#
name: jbod-infra
services:
poller:
image: docker.adamksmith.xyz/jbod-poller:latest # pin to a SHA in deploy
container_name: jbod-poller
restart: unless-stopped
privileged: true
pid: host
network_mode: host
volumes:
- /dev:/dev
- /sys:/sys:ro
- /run/udev:/run/udev:ro
environment:
- TZ=America/Denver
- ZFS_USE_NSENTER=true
- REDIS_HOST=127.0.0.1
- REDIS_PORT=6379
- REDIS_DB=0
# Pacing — keep the SAS bus quiet. POLL_CONCURRENCY=1 fully serializes.
- POLL_SWEEP_INTERVAL=300
- POLL_DRIVE_GAP=0.5
- POLL_CONCURRENCY=1
- POLL_STARTUP_JITTER=10
- POLL_DATA_TTL=900
depends_on:
- redis
redis:
image: redis:7-alpine
container_name: jbod-redis
restart: unless-stopped
network_mode: host
volumes:
- redis-data:/data
command: redis-server --save 60 1 --loglevel warning
volumes:
redis-data:

33
compose.web.yml Normal file
View File

@@ -0,0 +1,33 @@
# Web stack: REST API + UI + Home Assistant MQTT. Read-only consumer of the
# Redis filled by compose.infra.yml. Iterate freely here — this command only
# ever touches the `app` container, never the poller:
#
# docker compose -f compose.web.yml up -d --build
#
# Requires the infra stack (Redis) to be running. Reaches Redis at
# 127.0.0.1:6379 via host networking (separate compose project, shared host net).
name: jbod-web
services:
app:
image: docker.adamksmith.xyz/jbod-web:latest # pin to a SHA in deploy
container_name: jbod-monitor
restart: unless-stopped
network_mode: host
environment:
- TZ=America/Denver
- UVICORN_LOG_LEVEL=info
- REDIS_HOST=127.0.0.1
- REDIS_PORT=6379
- REDIS_DB=0
# Home Assistant MQTT publishing (EMQX, bridged to Mosquitto HA add-on).
- MQTT_HOST=10.5.30.3
- MQTT_PORT=1883
- MQTT_DISCOVERY_PREFIX=homeassistant
- MQTT_BASE_TOPIC=jbod-monitor
- MQTT_PUBLISH_INTERVAL=60
# Broker credentials (MQTT_USERNAME/MQTT_PASSWORD). Optional: container
# starts without it (MQTT just won't authenticate).
env_file:
- path: /etc/jbod-monitor/mqtt.env
required: false

View File

@@ -1,70 +0,0 @@
services:
# Producer: the ONLY service that touches hardware (smartctl/sg_ses/zpool/
# ledctl). Serialized + paced; writes everything to Redis.
poller:
build: .
image: docker.adamksmith.xyz/jbod-monitor:latest
container_name: jbod-poller
restart: unless-stopped
privileged: true
pid: host
network_mode: host
command: ["python", "-u", "poller.py"]
volumes:
- /dev:/dev
- /sys:/sys:ro
- /run/udev:/run/udev:ro
environment:
- TZ=America/Denver
- ZFS_USE_NSENTER=true
- REDIS_HOST=127.0.0.1
- REDIS_PORT=6379
- REDIS_DB=0
# Pacing — keep the SAS bus quiet. POLL_CONCURRENCY=1 fully serializes.
- POLL_SWEEP_INTERVAL=300
- POLL_DRIVE_GAP=0.5
- POLL_CONCURRENCY=1
- POLL_STARTUP_JITTER=10
- POLL_DATA_TTL=900
depends_on:
- redis
# Consumer: REST API + web UI + Home Assistant MQTT. Reads Redis only —
# unprivileged, no /dev, can restart freely without touching hardware.
app:
build: .
image: docker.adamksmith.xyz/jbod-monitor:latest
container_name: jbod-monitor
restart: unless-stopped
network_mode: host
environment:
- TZ=America/Denver
- UVICORN_LOG_LEVEL=info
- REDIS_HOST=127.0.0.1
- REDIS_PORT=6379
- REDIS_DB=0
# Home Assistant MQTT publishing (EMQX, bridged to Mosquitto HA add-on).
- MQTT_HOST=10.5.30.3
- MQTT_PORT=1883
- MQTT_DISCOVERY_PREFIX=homeassistant
- MQTT_BASE_TOPIC=jbod-monitor
- MQTT_PUBLISH_INTERVAL=60
# Broker credentials (MQTT_USERNAME/MQTT_PASSWORD). Optional: container
# starts without it (MQTT just won't authenticate).
env_file:
- path: /etc/jbod-monitor/mqtt.env
required: false
depends_on:
- redis
redis:
image: redis:7-alpine
container_name: jbod-redis
restart: unless-stopped
network_mode: host
volumes:
- redis-data:/data
command: redis-server --save 60 1 --loglevel warning
volumes:
redis-data:

2
requirements-poller.txt Normal file
View File

@@ -0,0 +1,2 @@
# Poller image — hardware producer. Only needs Redis; no web stack.
redis>=5.0.0

6
requirements-web.txt Normal file
View File

@@ -0,0 +1,6 @@
# Web/API image — read-only consumer + MQTT. No hardware tools needed.
fastapi>=0.115.0
uvicorn>=0.34.0
pydantic>=2.10.0
redis>=5.0.0
paho-mqtt>=2.1.0