Dedicated hardware poller + read-only consumers #4
@@ -6,3 +6,5 @@ __pycache__
|
||||
.git
|
||||
.env
|
||||
*.md
|
||||
tmp/
|
||||
.venv/
|
||||
|
||||
29
Dockerfile
29
Dockerfile
@@ -1,4 +1,4 @@
|
||||
# ---- Stage 1: Build frontend ----
|
||||
# ---- Stage: build frontend ----
|
||||
FROM node:22-alpine AS frontend-build
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
@@ -7,8 +7,10 @@ COPY frontend/ ./
|
||||
RUN npm run build
|
||||
# Output: /app/static/
|
||||
|
||||
# ---- Stage 2: Production runtime ----
|
||||
FROM python:3.13-slim
|
||||
# ---- Target: poller (hardware producer) ----
|
||||
# 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 \
|
||||
smartmontools \
|
||||
@@ -20,16 +22,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY requirements-poller.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 poller.py .
|
||||
COPY routers/ routers/
|
||||
COPY services/ services/
|
||||
COPY models/ models/
|
||||
|
||||
# Copy built frontend from stage 1
|
||||
COPY --from=frontend-build /app/static/ static/
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
15
README.md
15
README.md
@@ -45,12 +45,21 @@ apt install smartmontools sg3-utils ledmon # Debian/Ubuntu
|
||||
|
||||
## 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
|
||||
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
|
||||
processes by hand instead:
|
||||
Pin both images to a known-good SHA in deploy (`./build.sh` tags `:<sha>` and
|
||||
`:latest` for both). To run the two processes by hand instead:
|
||||
|
||||
```bash
|
||||
REDIS_HOST=localhost sudo -E python poller.py # producer (needs root)
|
||||
|
||||
23
build.sh
23
build.sh
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="docker.adamksmith.xyz/jbod-monitor"
|
||||
REG="docker.adamksmith.xyz"
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
@@ -10,16 +10,21 @@ git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit, using HEAD"
|
||||
else
|
||||
git commit -m "${1:-Build and push image}"
|
||||
git commit -m "${1:-Build and push images}"
|
||||
fi
|
||||
|
||||
SHA=$(git rev-parse --short HEAD)
|
||||
|
||||
echo "Building ${IMAGE}:${SHA}"
|
||||
docker build -t "${IMAGE}:${SHA}" -t "${IMAGE}:latest" .
|
||||
# Two images from one Dockerfile (shared frontend build stage is cached):
|
||||
# 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}"
|
||||
docker push "${IMAGE}:${SHA}"
|
||||
docker push "${IMAGE}:latest"
|
||||
|
||||
echo "Done: ${IMAGE}:${SHA}"
|
||||
echo "Done: ${REG}/jbod-{poller,web}:${SHA}"
|
||||
|
||||
47
compose.infra.yml
Normal file
47
compose.infra.yml
Normal 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
33
compose.web.yml
Normal 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
|
||||
@@ -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
2
requirements-poller.txt
Normal 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
6
requirements-web.txt
Normal 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
|
||||
Reference in New Issue
Block a user