diff --git a/.dockerignore b/.dockerignore index df7066d..bfd1096 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,3 +6,5 @@ __pycache__ .git .env *.md +tmp/ +.venv/ diff --git a/Dockerfile b/Dockerfile index 71cf3cc..ef11d8d 100644 --- a/Dockerfile +++ b/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 diff --git a/README.md b/README.md index 0733b4f..b848881 100644 --- a/README.md +++ b/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 `:` and +`:latest` for both). To run the two processes by hand instead: ```bash REDIS_HOST=localhost sudo -E python poller.py # producer (needs root) diff --git a/build.sh b/build.sh index 2abb9cc..0725d9e 100755 --- a/build.sh +++ b/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}" diff --git a/compose.infra.yml b/compose.infra.yml new file mode 100644 index 0000000..a2eb852 --- /dev/null +++ b/compose.infra.yml @@ -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: diff --git a/compose.web.yml b/compose.web.yml new file mode 100644 index 0000000..dd58d64 --- /dev/null +++ b/compose.web.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 4b1f81e..0000000 --- a/docker-compose.yml +++ /dev/null @@ -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: diff --git a/requirements-poller.txt b/requirements-poller.txt new file mode 100644 index 0000000..a0390dd --- /dev/null +++ b/requirements-poller.txt @@ -0,0 +1,2 @@ +# Poller image — hardware producer. Only needs Redis; no web stack. +redis>=5.0.0 diff --git a/requirements-web.txt b/requirements-web.txt new file mode 100644 index 0000000..20eeae1 --- /dev/null +++ b/requirements-web.txt @@ -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