From a9083ac07532e6b1bfe4d1da47d9174fd02f59d5 Mon Sep 17 00:00:00 2001 From: Jeb Baugh Date: Mon, 26 Jan 2026 19:18:49 -0600 Subject: [PATCH 1/2] add token headers to device --- ansible/host_vars/beamrider-0001.local.yml | 2 +- ansible/roles/pi_log/templates/config.toml.j2 | 12 ++++--- .../roles/pi_log/templates/pi-log.service.j2 | 3 +- app/ingestion/api_client.py | 31 ++++++++++++++++--- 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/ansible/host_vars/beamrider-0001.local.yml b/ansible/host_vars/beamrider-0001.local.yml index 549201b..497a89f 100644 --- a/ansible/host_vars/beamrider-0001.local.yml +++ b/ansible/host_vars/beamrider-0001.local.yml @@ -2,5 +2,5 @@ pi_log_push_enabled: true pi_log_push_url: "http://keep-0001.local:8000/api/geiger/push" -pi_log_api_token: "pi-log-placeholder-token" +pi_log_api_token: "bUN096GU2NadLRXEimB2Ofom08cfdjGyi5UOUk3WXIo" pi_log_device_id: "{{ inventory_hostname }}" diff --git a/ansible/roles/pi_log/templates/config.toml.j2 b/ansible/roles/pi_log/templates/config.toml.j2 index 29e3edc..7bf1d17 100644 --- a/ansible/roles/pi_log/templates/config.toml.j2 +++ b/ansible/roles/pi_log/templates/config.toml.j2 @@ -1,4 +1,4 @@ -# ansible/roles/pi_log/templates/config.toml.j2 +# filename: ansible/roles/pi_log/templates/config.toml.j2 [serial] device = "/dev/ttyUSB0" @@ -36,9 +36,13 @@ base_url = "" token = "" [push] -enabled = false -url = "" -api_key = "" +enabled = {{ pi_log_push_enabled | lower }} +url = "{{ pi_log_push_url }}" +api_key = "{{ pi_log_api_token }}" + +[device] +name = "{{ pi_log_device_id }}" +token = "{{ pi_log_device_token }}" [telemetry] enabled = false diff --git a/ansible/roles/pi_log/templates/pi-log.service.j2 b/ansible/roles/pi_log/templates/pi-log.service.j2 index b8ba5ef..27649b6 100644 --- a/ansible/roles/pi_log/templates/pi-log.service.j2 +++ b/ansible/roles/pi_log/templates/pi-log.service.j2 @@ -19,12 +19,13 @@ ExecStart=/opt/pi-log/.venv/bin/python3.11 -m app.ingestion.geiger_reader \ --device-type "{{ pi_log_device_type }}" \ --db "{{ pi_log_db_path }}" \ --device-id "{{ pi_log_device_id }}" \ + --device-name "{{ pi_log_device_id }}" \ + --device-token "{{ pi_log_device_token }}" \ {% if pi_log_push_enabled %} --api-url "{{ pi_log_push_url }}" \ --api-token "{{ pi_log_api_token }}" \ {% endif %} - # Kill any stale serial users after exit ExecStopPost=/bin/sh -c '/usr/bin/fuser -k "{{ pi_log_device }}" || true' diff --git a/app/ingestion/api_client.py b/app/ingestion/api_client.py index fcb2052..8e170ee 100755 --- a/app/ingestion/api_client.py +++ b/app/ingestion/api_client.py @@ -1,4 +1,4 @@ -# filename: app/api_client.py +# filename: pi-log/app/ingestion/api_client.py from __future__ import annotations @@ -17,10 +17,19 @@ class PushClient: - writes them to SQLite - pushes them immediately to the ingestion API - marks them pushed on success + + Device identity headers (X-Device-Name, X-Device-Token) are added to every + push request so Beamwarden can authenticate the device. """ def __init__( - self, api_url: str, api_token: str, device_id: str, db_path: str + self, + api_url: str, + api_token: str, + device_id: str, + db_path: str, + device_name: str | None = None, + device_token: str | None = None, ) -> None: if not api_url: raise ValueError("PushClient requires a non-empty api_url") @@ -30,6 +39,10 @@ def __init__( self.device_id = device_id self.db_path = db_path + # Device identity for Beamwarden + self.device_name = device_name or device_id + self.device_token = device_token or "" + self._conn = sqlite3.connect(self.db_path, check_same_thread=False) self._conn.execute("PRAGMA journal_mode=WAL;") self._conn.execute("PRAGMA synchronous=NORMAL;") @@ -94,13 +107,23 @@ def _push_single(self, record: GeigerRecord) -> bool: Push a single GeigerRecord to the ingestion endpoint. Returns True on success. """ - headers = {} + + # Device identity headers for Beamwarden + headers = { + "X-Device-Name": self.device_name, + "X-Device-Token": self.device_token, + } + + # Legacy LogExp token (optional) if self.api_token: headers["Authorization"] = f"Bearer {self.api_token}" try: resp = requests.post( - self.ingest_url, json=record.to_logexp_payload(), headers=headers + self.ingest_url, + json=record.to_logexp_payload(), + headers=headers, + timeout=5, ) resp.raise_for_status() return True From 127aa5b1a788c0ede07fb11257dcd81fe30c99a6 Mon Sep 17 00:00:00 2001 From: Jeb Baugh Date: Thu, 12 Feb 2026 18:20:27 -0600 Subject: [PATCH 2/2] Rewrite Makefile for unified local dev, container, and Pi ops - Replace legacy ingestion_loop entrypoint with run.py wrapper - Integrate scripts/run_local.sh as canonical local dev path - Add `dev-run`, `health`, and container log targets - Add fully commented sections for Python, Docker, Ansible, and Pi ops - Remove dead paths and outdated module references - Align Makefile with geiger_reader entrypoint + health server model - Preserve CI, linting, typecheck, and deployment workflows - Improve readability and future maintainer clarity --- Dockerfile | 25 ++++ Makefile | 155 +++++++++++++-------- ansible/host_vars/beamrider-0001.local.yml | 14 +- app/health.py | 28 +++- app/ingestion/geiger_reader.py | 3 + etc/logrotate.d/pi-log | 8 ++ etc/systemd/system/pi-log.service | 21 +++ run.py | 7 + scripts/run_local.sh | 27 +++- 9 files changed, 222 insertions(+), 66 deletions(-) create mode 100644 Dockerfile create mode 100644 etc/logrotate.d/pi-log create mode 100644 etc/systemd/system/pi-log.service diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2dce929 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# filename: Dockerfile +FROM python:3.11-slim + +# Install OS packages needed for serial access +RUN apt-get update && apt-get install -y --no-install-recommends \ + libudev1 \ + && rm -rf /var/lib/apt/lists/* + +# Create app directory +WORKDIR /app + +# Copy dependency list first for layer caching +COPY requirements.txt . + +# Install dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Default command: run the ingestion agent +CMD ["python", "-m", "app.ingestion.geiger_reader"] + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD python3 -c "import requests; requests.get('http://localhost:8080/health').raise_for_status()" || exit 1 diff --git a/Makefile b/Makefile index f019343..3aef1f5 100644 --- a/Makefile +++ b/Makefile @@ -1,23 +1,38 @@ # filename: Makefile -# ------------------------------------------------------------ -# pi-log Unified Makefile (Python + Ansible + Pi Ops) -# ------------------------------------------------------------ - -PI_HOST=beamrider-0001.local -PI_USER=jeb - -ANSIBLE_DIR=ansible -INVENTORY=$(ANSIBLE_DIR)/inventory -PLAYBOOK=$(ANSIBLE_DIR)/deploy.yml -ROLE_DIR=$(ANSIBLE_DIR)/roles/pi_log -SERVICE=pi-log - -PYTHON := /opt/homebrew/bin/python3.12 -VENV := .venv - -# ------------------------------------------------------------ +# ======================================================================== +# Beamwarden pi-log — Unified Makefile +# Python Dev • Local Ingestion • Docker • Ansible • Pi Ops +# ======================================================================== +# This Makefile provides a single, consistent interface for: +# - Local development (venv, lint, typecheck, tests) +# - Running the ingestion agent locally (via run.py) +# - Building + pushing the container image +# - Deploying to the Raspberry Pi via Ansible +# - Managing the pi-log systemd service on the Pi +# - Health checks, logs, and maintenance +# ======================================================================== + +# ------------------------------------------------------------------------ +# Configuration +# ------------------------------------------------------------------------ + +PI_HOST := beamrider-0001.local +PI_USER := jeb +SERVICE := pi-log + +ANSIBLE_DIR := ansible +INVENTORY := $(ANSIBLE_DIR)/inventory +PLAYBOOK := $(ANSIBLE_DIR)/deploy.yml + +IMAGE := ghcr.io/beamwarden/pi-log +TAG := latest + +PYTHON := /opt/homebrew/bin/python3.12 +VENV := .venv + +# ------------------------------------------------------------------------ # Help -# ------------------------------------------------------------ +# ------------------------------------------------------------------------ help: ## Show help @echo "" @@ -28,21 +43,17 @@ help: ## Show help .PHONY: help -# ------------------------------------------------------------ -# Python environment -# ------------------------------------------------------------ +# ------------------------------------------------------------------------ +# Python Environment +# ------------------------------------------------------------------------ -dev: ## Full local development bootstrap (venv + deps + sanity checks) +dev: ## Full local dev bootstrap (fresh venv + deps + sanity checks) rm -rf $(VENV) $(PYTHON) -m venv $(VENV) - @echo ">>> Upgrading pip" $(VENV)/bin/pip install --upgrade pip - @echo ">>> Installing all dependencies from requirements.txt" $(VENV)/bin/pip install -r requirements.txt - @echo ">>> Verifying interpreter" $(VENV)/bin/python3 --version - @echo ">>> Verifying core imports" - $(VENV)/bin/python3 -c "import app, app.ingestion.api_client, pytest, fastapi, requests; print('Imports OK')" + $(VENV)/bin/python3 -c "import app, pytest, requests; print('Imports OK')" @echo "" @echo "✔ Development environment ready" @echo "Activate with: source $(VENV)/bin/activate" @@ -52,7 +63,6 @@ bootstrap: ## Create venv and install dependencies (first-time setup) $(PYTHON) -m venv $(VENV) $(VENV)/bin/pip install --upgrade pip $(VENV)/bin/pip install -r requirements.txt - @echo "Bootstrap complete. Activate with: source $(VENV)/bin/activate" install: check-venv ## Install dependencies into existing venv $(VENV)/bin/pip install -r requirements.txt @@ -63,17 +73,31 @@ freeze: ## Freeze dependencies to requirements.txt check-venv: @test -n "$$VIRTUAL_ENV" || (echo "ERROR: .venv not activated"; exit 1) -run: check-venv ## Run ingestion loop locally - $(VENV)/bin/python -m app.ingestion_loop - clean-pyc: find . -type d -name "__pycache__" -exec rm -rf {} + -# ------------------------------------------------------------ -# Linting, type checking, tests -# ------------------------------------------------------------ +clean: ## Remove venv + Python cache files + rm -rf $(VENV) + find . -type d -name "__pycache__" -exec rm -rf {} + + find . -type f -name "*.pyc" -delete -lint: check-venv ## Run ruff lint + ruff format check +# ------------------------------------------------------------------------ +# Local Ingestion Agent (run.py + config.toml) +# ------------------------------------------------------------------------ + +run: check-venv ## Run ingestion agent locally (via scripts/run_local.sh) + ./scripts/run_local.sh + +dev-run: run ## Alias for local ingestion run + +health: ## Curl the local health endpoint + curl -s http://localhost:8080/health | jq . + +# ------------------------------------------------------------------------ +# Linting, Type Checking, Tests +# ------------------------------------------------------------------------ + +lint: check-venv ## Run ruff lint + format check $(VENV)/bin/ruff check . $(VENV)/bin/ruff format --check . @@ -83,19 +107,37 @@ typecheck: check-venv ## Run mypy type checking test: check-venv ## Run pytest suite $(VENV)/bin/pytest -q -ci: clean-pyc check-venv ## Run full local CI suite (lint + typecheck + tests) +ci: clean-pyc check-venv ## Full local CI (lint + typecheck + tests) $(VENV)/bin/ruff check . $(VENV)/bin/mypy . $(VENV)/bin/pytest -q @echo "" @echo "✔ Local CI passed" -# ------------------------------------------------------------ -# Deployment to Raspberry Pi (via Ansible) -# ------------------------------------------------------------ +# ------------------------------------------------------------------------ +# Docker Build + Push +# ------------------------------------------------------------------------ + +build: ## Build the pi-log container image + docker build -t $(IMAGE):$(TAG) . + +push: ## Push the container image to GHCR + docker push $(IMAGE):$(TAG) + +run-container: ## Run the container locally (binds config.toml + serial) + docker run --rm -it \ + --device /dev/ttyUSB0 \ + --volume $(PWD)/config.toml:/app/config.toml:ro \ + $(IMAGE):$(TAG) + +logs-container: ## Tail logs from a locally running container + docker logs -f pi-log + +# ------------------------------------------------------------------------ +# Ansible Deployment +# ------------------------------------------------------------------------ check-ansible: ## Validate Ansible syntax, inventory, lint, and dry-run - # ansible.cfg defines 'inventory = $(INVENTORY)' ansible-playbook $(PLAYBOOK) --syntax-check ansible-inventory --list >/dev/null ansible-lint $(ANSIBLE_DIR) @@ -104,9 +146,12 @@ check-ansible: ## Validate Ansible syntax, inventory, lint, and dry-run deploy: ## Deploy to Raspberry Pi via Ansible ansible-playbook $(PLAYBOOK) -# ------------------------------------------------------------ -# Pi service management -# ------------------------------------------------------------ +ansible-deploy: ## Run ansible/Makefile deploy + cd ansible && make deploy + +# ------------------------------------------------------------------------ +# Pi Service Management +# ------------------------------------------------------------------------ restart: ## Restart pi-log service on the Pi ansible beamrider-0001 -m systemd -a "name=$(SERVICE) state=restarted" @@ -120,18 +165,18 @@ stop: ## Stop pi-log service status: ## Show pi-log systemd status ssh $(PI_USER)@$(PI_HOST) "systemctl status $(SERVICE)" -logs: ## Show last 50 log lines +logs: ## Show last 50 log lines from the Pi ssh $(PI_USER)@$(PI_HOST) "sudo journalctl -u $(SERVICE) -n 50" -tail: ## Follow live logs +tail: ## Follow live logs from the Pi ssh $(PI_USER)@$(PI_HOST) "sudo journalctl -u $(SERVICE) -f" db-shell: ## Open SQLite shell on the Pi ssh $(PI_USER)@$(PI_HOST) "sudo sqlite3 /opt/pi-log/readings.db" -# ------------------------------------------------------------ -# Pi health + maintenance -# ------------------------------------------------------------ +# ------------------------------------------------------------------------ +# Pi Health + Maintenance +# ------------------------------------------------------------------------ ping: ## Ping the Raspberry Pi via Ansible ansible beamrider-0001 -m ping @@ -142,7 +187,7 @@ hosts: ## Show parsed Ansible inventory ssh: ## SSH into the Raspberry Pi ssh $(PI_USER)@$(PI_HOST) -doctor: ## Run full environment + Pi health checks +doctor: ## Full environment + Pi health checks @echo "Checking Python..."; python3 --version; echo "" @echo "Checking virtual environment..."; \ [ -d ".venv" ] && echo "venv OK" || echo "venv missing"; echo "" @@ -154,20 +199,8 @@ doctor: ## Run full environment + Pi health checks @echo "Checking systemd service..."; \ ssh $(PI_USER)@$(PI_HOST) "systemctl is-active $(SERVICE)" || true -clean: ## Remove virtual environment and Python cache files - rm -rf $(VENV) - find . -type d -name "__pycache__" -exec rm -rf {} + - find . -type f -name "*.pyc" -delete - reset-pi: ## Wipe /opt/pi-log on the Pi and redeploy ssh $(PI_USER)@$(PI_HOST) "sudo systemctl stop $(SERVICE) || true" ssh $(PI_USER)@$(PI_HOST) "sudo rm -rf /opt/pi-log/*" ansible-playbook $(PLAYBOOK) ssh $(PI_USER)@$(PI_HOST) "sudo systemctl restart $(SERVICE)" - -# ------------------------------------------------------------ -# Delegation to ansible/Makefile (optional) -# ------------------------------------------------------------ - -ansible-deploy: ## Run ansible/Makefile deploy - cd ansible && make deploy diff --git a/ansible/host_vars/beamrider-0001.local.yml b/ansible/host_vars/beamrider-0001.local.yml index 497a89f..8a69f76 100644 --- a/ansible/host_vars/beamrider-0001.local.yml +++ b/ansible/host_vars/beamrider-0001.local.yml @@ -1,6 +1,16 @@ # filename: ansible/host_vars/beamrider-0001.yml +# pi-log should continue pushing to KEEP pi_log_push_enabled: true + +# KEEP is the ingestion endpoint pi_log_push_url: "http://keep-0001.local:8000/api/geiger/push" -pi_log_api_token: "bUN096GU2NadLRXEimB2Ofom08cfdjGyi5UOUk3WXIo" -pi_log_device_id: "{{ inventory_hostname }}" + +# Legacy LogExp token (unused for Beamwarden) +pi_log_api_token: "" + +# Device identity for Beamwarden +pi_log_device_id: "beamrider-0001" + +# Will be replaced by encrypted token after provisioning +pi_log_device_token: "bUN096GU2NadLRXEimB2Ofom08cfdjGyi5UOUk3WXIo" diff --git a/app/health.py b/app/health.py index b564641..eba98a0 100755 --- a/app/health.py +++ b/app/health.py @@ -2,11 +2,35 @@ from __future__ import annotations from typing import Dict +import json +from http.server import BaseHTTPRequestHandler, HTTPServer +import threading def health_check() -> Dict[str, str]: """ - Simple health check endpoint for tests and monitoring. - Returns a dict with a static OK status. + Simple health check for tests and monitoring. """ return {"status": "ok"} + + +class HealthHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/health": + payload = json.dumps(health_check()).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(payload) + else: + self.send_response(404) + self.end_headers() + + +def start_health_server(port: int = 8080) -> None: + """ + Starts a background HTTP server exposing /health. + """ + server = HTTPServer(("0.0.0.0", port), HealthHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() diff --git a/app/ingestion/geiger_reader.py b/app/ingestion/geiger_reader.py index b1f40a9..69385d2 100755 --- a/app/ingestion/geiger_reader.py +++ b/app/ingestion/geiger_reader.py @@ -7,6 +7,8 @@ from app.ingestion.api_client import PushClient from app.ingestion.serial_reader import SerialReader from app.ingestion.watchdog import WatchdogSerialReader +from app.health import start_health_server + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( @@ -32,6 +34,7 @@ def main() -> int: level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", ) + start_health_server() logging.info("Starting ingestion agent") logging.info(f"Device: {args.device}") diff --git a/etc/logrotate.d/pi-log b/etc/logrotate.d/pi-log new file mode 100644 index 0000000..763b2b7 --- /dev/null +++ b/etc/logrotate.d/pi-log @@ -0,0 +1,8 @@ +/var/log/pi-log/*.log { + daily + rotate 7 + compress + missingok + notifempty + copytruncate +} diff --git a/etc/systemd/system/pi-log.service b/etc/systemd/system/pi-log.service new file mode 100644 index 0000000..30547d0 --- /dev/null +++ b/etc/systemd/system/pi-log.service @@ -0,0 +1,21 @@ +# filename: /etc/systemd/system/pi-log.service +[Unit] +Description=Pi Log Ingestion (Container) +After=network-online.target +Wants=network-online.target + +[Service] +Restart=always +RestartSec=2 + +# Mount config and expose serial device +ExecStart=/usr/bin/docker run --rm \ + --name pi-log \ + --device=/dev/ttyUSB0 \ + -v /opt/pi-log/config.toml:/app/config.toml:ro \ + beamwarden/pi-log:latest + +ExecStop=/usr/bin/docker stop pi-log + +[Install] +WantedBy=multi-user.target diff --git a/run.py b/run.py index e69de29..e2815af 100644 --- a/run.py +++ b/run.py @@ -0,0 +1,7 @@ +# filename: run.py + +import sys +from app.ingestion.geiger_reader import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/run_local.sh b/scripts/run_local.sh index 283a138..590d14f 100755 --- a/scripts/run_local.sh +++ b/scripts/run_local.sh @@ -1,9 +1,34 @@ #!/usr/bin/env bash set -euo pipefail +# Ensure we're in the repo root +cd "$(dirname "$0")/.." + +# Ensure config exists +if [ ! -f config.toml ]; then + echo "config.toml not found. Copy config.toml.example and edit it." + exit 1 +fi + # Activate venv if present if [ -d ".venv" ]; then source .venv/bin/activate fi -python run.py +# Extract values from config.toml +DEVICE=$(grep '^device' config.toml | cut -d '=' -f2 | tr -d ' "') +BAUDRATE=$(grep '^baudrate' config.toml | cut -d '=' -f2 | tr -d ' "') +DB=$(grep '^db_path' config.toml | cut -d '=' -f2 | tr -d ' "') +API_URL=$(grep '^endpoint' config.toml | cut -d '=' -f2 | tr -d ' "') +API_TOKEN=$(grep '^api_token' config.toml | cut -d '=' -f2 | tr -d ' "') +DEVICE_ID=$(grep '^node_id' config.toml | cut -d '=' -f2 | tr -d ' "') + +# Run the ingestion agent via run.py +python run.py \ + --device "$DEVICE" \ + --baudrate "$BAUDRATE" \ + --device-type mightyohm \ + --db "$DB" \ + --api-url "$API_URL" \ + --api-token "$API_TOKEN" \ + --device-id "$DEVICE_ID"