Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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
155 changes: 94 additions & 61 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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 ""
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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 .

Expand All @@ -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)
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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 ""
Expand All @@ -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
14 changes: 12 additions & 2 deletions ansible/host_vars/beamrider-0001.local.yml
Original file line number Diff line number Diff line change
@@ -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: "pi-log-placeholder-token"
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"
12 changes: 8 additions & 4 deletions ansible/roles/pi_log/templates/config.toml.j2
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
3 changes: 2 additions & 1 deletion ansible/roles/pi_log/templates/pi-log.service.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
28 changes: 26 additions & 2 deletions app/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading