mirror of
https://github.com/jorijn/meshcore-stats.git
synced 2026-03-28 17:42:55 +01:00
feat: add Docker containerization with GitHub Actions CI/CD
- Multi-stage Dockerfile with Python 3.12 + Ofelia scheduler - docker-compose.yml for production (ghcr.io image) - docker-compose.development.yml for local builds - GitHub Actions workflow for multi-arch builds (amd64/arm64) - Security hardening: non-root user, cap_drop, read_only filesystem - Trivy vulnerability scanning and SBOM generation - Nightly rebuilds for OS security patches 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
42
.dockerignore
Normal file
42
.dockerignore
Normal file
@@ -0,0 +1,42 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
.venv
|
||||
venv
|
||||
ENV
|
||||
env
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Project directories (generated/runtime)
|
||||
data/
|
||||
out/
|
||||
|
||||
# Configuration (use environment variables in Docker)
|
||||
.envrc
|
||||
.direnv
|
||||
meshcore.conf
|
||||
|
||||
# Documentation
|
||||
docs/
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# Development files
|
||||
.claude/
|
||||
*.log
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
233
.github/workflows/docker-publish.yml
vendored
Normal file
233
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -0,0 +1,233 @@
|
||||
# Build and publish Docker images to GitHub Container Registry
|
||||
#
|
||||
# Triggers:
|
||||
# - On release: Build with version tags (X.Y.Z, X.Y, latest)
|
||||
# - On schedule: Rebuild all tags with fresh base image (OS patches)
|
||||
# - Manual: For testing, optional push
|
||||
#
|
||||
# Security:
|
||||
# - All actions pinned by SHA
|
||||
# - Vulnerability scanning with Trivy
|
||||
# - SBOM and provenance attestation
|
||||
|
||||
name: Docker Build and Publish
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
schedule:
|
||||
# Daily at 4 AM UTC - rebuild with fresh base image
|
||||
- cron: "0 4 * * *"
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push:
|
||||
description: "Push image to registry"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
# For nightly builds, get the latest release version
|
||||
- name: Get latest release version
|
||||
id: get-version
|
||||
if: github.event_name == 'schedule'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Get latest release tag
|
||||
LATEST_TAG=$(gh release view --json tagName -q '.tagName' 2>/dev/null || echo "")
|
||||
if [ -z "$LATEST_TAG" ]; then
|
||||
echo "No releases found, skipping nightly build"
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
# Strip 'v' prefix if present
|
||||
VERSION=$(echo "$LATEST_TAG" | sed 's/^v//')
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Skip if no releases
|
||||
if: github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true'
|
||||
run: |
|
||||
echo "No releases found, skipping nightly build"
|
||||
exit 0
|
||||
|
||||
- name: Set up QEMU
|
||||
if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true')"
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true')"
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Log in to Container Registry
|
||||
if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true')"
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Generate tags based on event type
|
||||
- name: Extract metadata (release)
|
||||
id: meta-release
|
||||
if: github.event_name == 'release'
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
# X.Y.Z
|
||||
type=semver,pattern={{version}}
|
||||
# X.Y
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
# latest
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Extract metadata (nightly)
|
||||
id: meta-nightly
|
||||
if: github.event_name == 'schedule' && steps.get-version.outputs.skip != 'true'
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
# Rebuild version tags with OS patches
|
||||
type=raw,value=${{ steps.get-version.outputs.version }}
|
||||
# Nightly tags
|
||||
type=raw,value=nightly
|
||||
type=raw,value=nightly-{{date 'YYYYMMDD'}}
|
||||
# Also update latest with security patches
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Extract metadata (manual)
|
||||
id: meta-manual
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,prefix=sha-
|
||||
|
||||
# Build image (release - with cache)
|
||||
- name: Build and push (release)
|
||||
id: build-release
|
||||
if: github.event_name == 'release'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta-release.outputs.tags }}
|
||||
labels: ${{ steps.meta-release.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
# Build image (nightly - no cache, fresh base)
|
||||
- name: Build and push (nightly)
|
||||
id: build-nightly
|
||||
if: github.event_name == 'schedule' && steps.get-version.outputs.skip != 'true'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta-nightly.outputs.tags }}
|
||||
labels: ${{ steps.meta-nightly.outputs.labels }}
|
||||
pull: true
|
||||
no-cache: true
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
# Build image (manual)
|
||||
- name: Build and push (manual)
|
||||
id: build-manual
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ inputs.push }}
|
||||
tags: ${{ steps.meta-manual.outputs.tags }}
|
||||
labels: ${{ steps.meta-manual.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
# Determine image tag for scanning and testing
|
||||
- name: Determine image tag
|
||||
id: image-tag
|
||||
if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true')"
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
# Strip 'v' prefix to match semver tag format from metadata-action
|
||||
echo "tag=$(echo '${{ github.event.release.tag_name }}' | sed 's/^v//')" >> $GITHUB_OUTPUT
|
||||
elif [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
echo "tag=nightly" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tag=sha-${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Vulnerability scanning
|
||||
- name: Run Trivy vulnerability scanner
|
||||
if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true')"
|
||||
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.image-tag.outputs.tag }}
|
||||
format: "sarif"
|
||||
output: "trivy-results.sarif"
|
||||
severity: "CRITICAL,HIGH"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Trivy scan results
|
||||
if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true')"
|
||||
uses: github/codeql-action/upload-sarif@6e4b8622b82fab3c6ad2a7814fad1effc7615bc8 # v3.28.4
|
||||
with:
|
||||
sarif_file: "trivy-results.sarif"
|
||||
continue-on-error: true
|
||||
|
||||
# Smoke test - verify image runs correctly
|
||||
- name: Smoke test
|
||||
if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true')"
|
||||
run: |
|
||||
IMAGE_TAG="${{ steps.image-tag.outputs.tag }}"
|
||||
|
||||
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${IMAGE_TAG}
|
||||
|
||||
# Test that Python and key modules are available
|
||||
docker run --rm ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${IMAGE_TAG} \
|
||||
python -c "from meshmon.db import init_db; from meshmon.env import get_config; print('Smoke test passed')"
|
||||
|
||||
# Attestation (releases only)
|
||||
- name: Generate attestation
|
||||
if: github.event_name == 'release'
|
||||
uses: actions/attest-build-provenance@46a583fd92dfbf46b772907a9740f888f4324bb9 # v3.1.0
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.build-release.outputs.digest }}
|
||||
push-to-registry: true
|
||||
95
CLAUDE.md
95
CLAUDE.md
@@ -171,6 +171,101 @@ Phase 3: Render → Static HTML site (inline SVG)
|
||||
Phase 4: Render → Reports (monthly/yearly statistics)
|
||||
```
|
||||
|
||||
## Docker Architecture
|
||||
|
||||
The project provides Docker containerization for easy deployment. Two containers work together:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Docker Compose │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────────────────┐ │
|
||||
│ │ meshcore-stats │ │ nginx │ │
|
||||
│ │ ┌───────────────┐ │ │ │ │
|
||||
│ │ │ Ofelia │ │ │ Serves static site on :8080 │ │
|
||||
│ │ │ (scheduler) │ │ │ │ │
|
||||
│ │ └───────┬───────┘ │ └──────────────▲──────────────────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ │ ┌──────▼──────┐ │ ┌─────────┴─────────┐ │
|
||||
│ │ │ Python │ │ │ output_data │ │
|
||||
│ │ │ Scripts │───┼────────►│ (named volume) │ │
|
||||
│ │ └─────────────┘ │ └───────────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └──────────┼──────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────▼────────┐ │
|
||||
│ │ ./data/state │ │
|
||||
│ │ (bind mount) │ │
|
||||
│ └─────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Container Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Dockerfile` | Multi-stage build: Python + Ofelia scheduler |
|
||||
| `docker-compose.yml` | Production deployment using published ghcr.io image |
|
||||
| `docker-compose.development.yml` | Development override for local builds |
|
||||
| `docker/ofelia.ini` | Scheduler configuration (cron jobs) |
|
||||
| `docker/nginx.conf` | nginx configuration for static site serving |
|
||||
| `.dockerignore` | Files excluded from Docker build context |
|
||||
|
||||
### docker-compose.yml vs docker-compose.development.yml
|
||||
|
||||
**Production** (`docker-compose.yml`):
|
||||
- Uses published image from `ghcr.io/jorijn/meshcore-stats`
|
||||
- Image version managed by release-please via `x-release-please-version` placeholder
|
||||
- Suitable for end users
|
||||
|
||||
**Development** (`docker-compose.development.yml`):
|
||||
- Override file that builds locally instead of pulling from registry
|
||||
- Mounts `src/` and `scripts/` for live code changes
|
||||
- Usage: `docker compose -f docker-compose.yml -f docker-compose.development.yml up --build`
|
||||
|
||||
### Ofelia Scheduler
|
||||
|
||||
[Ofelia](https://github.com/mcuadros/ofelia) is a lightweight job scheduler designed for Docker. It replaces cron for container environments.
|
||||
|
||||
Jobs configured in `docker/ofelia.ini`:
|
||||
- `collect-companion`: Every minute (with `no-overlap=true`)
|
||||
- `collect-repeater`: Every 15 minutes at :01, :16, :31, :46 (with `no-overlap=true`)
|
||||
- `render-charts`: Every 5 minutes
|
||||
- `render-site`: Every 5 minutes
|
||||
- `render-reports`: Daily at midnight
|
||||
- `db-maintenance`: Monthly at 3 AM on the 1st
|
||||
|
||||
### GitHub Actions Workflow
|
||||
|
||||
`.github/workflows/docker-publish.yml` builds and publishes Docker images:
|
||||
|
||||
| Trigger | Tags Created |
|
||||
|---------|--------------|
|
||||
| Release | `X.Y.Z`, `X.Y`, `latest` |
|
||||
| Nightly (4 AM UTC) | Rebuilds all version tags + `nightly`, `nightly-YYYYMMDD` |
|
||||
| Manual | `sha-xxxxxx` |
|
||||
|
||||
**Nightly rebuilds** ensure version tags always include the latest OS security patches. This is a common pattern used by official Docker images (nginx, postgres, node). Users needing reproducibility should pin by SHA digest or use dated nightly tags.
|
||||
|
||||
All GitHub Actions are pinned by full SHA for security. Dependabot can be configured to update these automatically.
|
||||
|
||||
### Version Placeholder
|
||||
|
||||
The version in `docker-compose.yml` uses release-please's placeholder syntax:
|
||||
```yaml
|
||||
image: ghcr.io/jorijn/meshcore-stats:0.3.0 # x-release-please-version
|
||||
```
|
||||
|
||||
This is automatically updated when a new release is created.
|
||||
|
||||
### Agent Review Guidelines
|
||||
|
||||
When reviewing Docker-related changes, always provide the **full plan or implementation** to review agents. Do not summarize or abbreviate - agents need complete context to provide accurate feedback.
|
||||
|
||||
Relevant agents for Docker reviews:
|
||||
- **k8s-security-reviewer**: Container security, RBAC, secrets handling
|
||||
- **cicd-pipeline-specialist**: GitHub Actions workflows, build pipelines
|
||||
- **python-code-reviewer**: Dockerfile Python-specific issues (venv PATH, runtime libs)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
|
||||
115
Dockerfile
Normal file
115
Dockerfile
Normal file
@@ -0,0 +1,115 @@
|
||||
# =============================================================================
|
||||
# Stage 1: Build dependencies
|
||||
# =============================================================================
|
||||
FROM python:3.12-slim-bookworm AS builder
|
||||
|
||||
# Ofelia version and checksums (verified from GitHub releases)
|
||||
ARG OFELIA_VERSION=0.3.12
|
||||
ARG TARGETARCH
|
||||
ARG OFELIA_SHA256_AMD64=cf06d2199abafbd3aa5afe0f8266e478818faacd11555b99200707321035c931
|
||||
ARG OFELIA_SHA256_ARM64=57760ef7f17a2cd55b5b1e1946f79b91b24bde40d47e81a0d75fd1470d883c1a
|
||||
|
||||
# Install build dependencies for Python packages
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libfreetype6-dev \
|
||||
libpng-dev \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Download and verify Ofelia binary in builder stage (keeps curl out of runtime)
|
||||
RUN set -ex; \
|
||||
if [ "$TARGETARCH" = "amd64" ]; then \
|
||||
OFELIA_SHA256="$OFELIA_SHA256_AMD64"; \
|
||||
elif [ "$TARGETARCH" = "arm64" ]; then \
|
||||
OFELIA_SHA256="$OFELIA_SHA256_ARM64"; \
|
||||
else \
|
||||
echo "Unsupported architecture: $TARGETARCH" && exit 1; \
|
||||
fi; \
|
||||
curl -fsSL "https://github.com/mcuadros/ofelia/releases/download/v${OFELIA_VERSION}/ofelia_${OFELIA_VERSION}_linux_${TARGETARCH}.tar.gz" -o /tmp/ofelia.tar.gz \
|
||||
&& echo "${OFELIA_SHA256} /tmp/ofelia.tar.gz" | sha256sum -c - \
|
||||
&& tar -xzf /tmp/ofelia.tar.gz -C /usr/local/bin ofelia \
|
||||
&& rm /tmp/ofelia.tar.gz \
|
||||
&& chmod +x /usr/local/bin/ofelia
|
||||
|
||||
# Create virtual environment
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# =============================================================================
|
||||
# Stage 2: Runtime
|
||||
# =============================================================================
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
# OCI Labels
|
||||
LABEL org.opencontainers.image.source="https://github.com/jorijn/meshcore-stats"
|
||||
LABEL org.opencontainers.image.description="MeshCore Stats - LoRa mesh network monitoring"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
# Install runtime dependencies
|
||||
# - tini: init system for proper signal handling
|
||||
# - libfreetype6, libpng16-16: matplotlib runtime libraries
|
||||
# - fontconfig, fonts-dejavu-core: fonts for chart text rendering
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tini \
|
||||
libfreetype6 \
|
||||
libpng16-16 \
|
||||
fontconfig \
|
||||
fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
# Build font cache for matplotlib
|
||||
&& fc-cache -f \
|
||||
# Remove setuid/setgid binaries for security
|
||||
&& find / -perm /6000 -type f -exec chmod a-s {} \; 2>/dev/null || true
|
||||
|
||||
# Create non-root user with dialout group for serial access
|
||||
RUN groupadd -g 1000 meshmon \
|
||||
&& useradd -u 1000 -g meshmon -G dialout -s /sbin/nologin meshmon \
|
||||
&& mkdir -p /data/state /out /tmp/matplotlib \
|
||||
&& chown -R meshmon:meshmon /data /out /tmp/matplotlib
|
||||
|
||||
# Copy Ofelia binary from builder (keeps curl out of runtime image)
|
||||
COPY --from=builder /usr/local/bin/ofelia /usr/local/bin/ofelia
|
||||
|
||||
# Copy virtual environment from builder
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
# Copy application code
|
||||
COPY --chown=meshmon:meshmon src/ /app/src/
|
||||
COPY --chown=meshmon:meshmon scripts/ /app/scripts/
|
||||
COPY --chown=meshmon:meshmon docker/ofelia.ini /app/ofelia.ini
|
||||
|
||||
# Environment configuration
|
||||
# - PATH: Include venv so Ofelia can run Python
|
||||
# - PYTHONPATH: Allow imports from src/meshmon
|
||||
# - PYTHONUNBUFFERED: Ensure logs are output immediately
|
||||
# - PYTHONDONTWRITEBYTECODE: Don't create .pyc files
|
||||
# - MPLCONFIGDIR: Matplotlib font cache directory
|
||||
# - STATE_DIR/OUT_DIR: Default paths for Docker volumes
|
||||
ENV PATH="/opt/venv/bin:$PATH" \
|
||||
PYTHONPATH=/app/src \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
MPLCONFIGDIR=/tmp/matplotlib \
|
||||
STATE_DIR=/data/state \
|
||||
OUT_DIR=/out
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Run as non-root user
|
||||
USER meshmon
|
||||
|
||||
# Use tini as init system for proper signal handling
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
|
||||
# Run Ofelia scheduler
|
||||
CMD ["ofelia", "daemon", "--config=/app/ofelia.ini"]
|
||||
|
||||
# Health check - verify database is accessible
|
||||
HEALTHCHECK --interval=5m --timeout=30s --start-period=60s --retries=3 \
|
||||
CMD python -c "import sqlite3; sqlite3.connect('/data/state/metrics.db').execute('SELECT 1')" || exit 1
|
||||
121
README.md
121
README.md
@@ -112,14 +112,129 @@ MESHCORE=/home/user/meshcore-stats
|
||||
- `cd $MESHCORE` is required because paths in the config are relative to the project root
|
||||
- `flock` prevents USB serial conflicts when companion and repeater collection overlap
|
||||
|
||||
### Docker / Container Usage
|
||||
### Docker Installation
|
||||
|
||||
When running in Docker, you can skip the config file and pass environment variables directly:
|
||||
The recommended way to run MeshCore Stats is with Docker Compose. This provides automatic scheduling of all collection and rendering tasks.
|
||||
|
||||
#### Quick Start
|
||||
|
||||
```bash
|
||||
docker run -e MESH_SERIAL_PORT=/dev/ttyUSB0 -e REPEATER_NAME="My Repeater" ...
|
||||
# Clone the repository
|
||||
git clone https://github.com/jorijn/meshcore-stats.git
|
||||
cd meshcore-stats
|
||||
|
||||
# Create configuration
|
||||
cp meshcore.conf.example meshcore.conf
|
||||
# Edit meshcore.conf with your settings
|
||||
|
||||
# Create data directory
|
||||
mkdir -p data/state
|
||||
|
||||
# Start the containers
|
||||
docker compose up -d
|
||||
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
The web interface will be available at `http://localhost:8080`.
|
||||
|
||||
#### Architecture
|
||||
|
||||
The Docker setup uses two containers:
|
||||
|
||||
| Container | Purpose |
|
||||
|-----------|---------|
|
||||
| `meshcore-stats` | Runs Ofelia scheduler for data collection and rendering |
|
||||
| `nginx` | Serves the static website |
|
||||
|
||||
#### Configuration
|
||||
|
||||
Configuration is loaded from `meshcore.conf` via the `env_file` directive. Key settings:
|
||||
|
||||
```bash
|
||||
# Required: Serial device for companion node
|
||||
MESH_SERIAL_PORT=/dev/ttyUSB0 # Adjust for your system
|
||||
|
||||
# Required: Repeater identity
|
||||
REPEATER_NAME="Your Repeater Name"
|
||||
REPEATER_PASSWORD="your-password"
|
||||
|
||||
# Display names (shown in UI)
|
||||
REPEATER_DISPLAY_NAME="My Repeater"
|
||||
COMPANION_DISPLAY_NAME="My Companion"
|
||||
```
|
||||
|
||||
See `meshcore.conf.example` for all available options.
|
||||
|
||||
#### Serial Device Access
|
||||
|
||||
The container needs access to your USB serial device. Update `docker-compose.yml` if your device path differs:
|
||||
|
||||
```yaml
|
||||
devices:
|
||||
- /dev/ttyACM0:/dev/ttyACM0:rwm # Adjust path as needed
|
||||
```
|
||||
|
||||
On the host, ensure the device is accessible:
|
||||
|
||||
```bash
|
||||
# Add user to dialout group (Linux)
|
||||
sudo usermod -a -G dialout $USER
|
||||
# Log out and back in for changes to take effect
|
||||
```
|
||||
|
||||
#### Development Mode
|
||||
|
||||
For local development with live code changes:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.development.yml up --build
|
||||
```
|
||||
|
||||
This mounts `src/` and `scripts/` into the container, so changes take effect immediately without rebuilding.
|
||||
|
||||
#### Image Tags
|
||||
|
||||
Images are published to `ghcr.io/jorijn/meshcore-stats`:
|
||||
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
| `X.Y.Z` | Specific version (e.g., `0.3.0`) |
|
||||
| `latest` | Latest release |
|
||||
| `nightly` | Latest release rebuilt with OS patches |
|
||||
| `nightly-YYYYMMDD` | Dated nightly build |
|
||||
|
||||
Version tags are rebuilt nightly to include OS security patches. For reproducible deployments, pin by SHA digest:
|
||||
|
||||
```yaml
|
||||
image: ghcr.io/jorijn/meshcore-stats@sha256:abc123...
|
||||
```
|
||||
|
||||
#### Volumes
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `./data/state` | SQLite database and circuit breaker state |
|
||||
| `output_data` | Generated static site (shared with nginx) |
|
||||
|
||||
#### Resource Limits
|
||||
|
||||
Default resource limits in `docker-compose.yml`:
|
||||
|
||||
| Container | CPU | Memory |
|
||||
|-----------|-----|--------|
|
||||
| meshcore-stats | 1.0 | 512MB |
|
||||
| nginx | 0.5 | 64MB |
|
||||
|
||||
Adjust in `docker-compose.yml` if needed.
|
||||
|
||||
#### Important Notes
|
||||
|
||||
- **Single instance only**: SQLite uses WAL mode which requires exclusive access. Do not run multiple container instances.
|
||||
- **Persistent storage**: Mount `./data/state` to preserve your database across container restarts.
|
||||
- **Health checks**: Both containers have health checks. Use `docker compose ps` to verify status.
|
||||
|
||||
Environment variables always take precedence over `meshcore.conf`.
|
||||
|
||||
### Serving the Site
|
||||
|
||||
29
docker-compose.development.yml
Normal file
29
docker-compose.development.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
# MeshCore Stats - Development Override
|
||||
#
|
||||
# Use this file for local development with live code changes.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.development.yml up --build
|
||||
#
|
||||
# This override:
|
||||
# - Builds the image locally instead of pulling from ghcr.io
|
||||
# - Mounts src/ and scripts/ for live code changes (no rebuild needed)
|
||||
|
||||
services:
|
||||
meshcore-stats:
|
||||
# Build locally instead of using published image
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
# Remove the image reference (use built image)
|
||||
image: meshcore-stats:development
|
||||
|
||||
# Mount source code for live development
|
||||
# Changes to Python files take effect immediately (no rebuild needed)
|
||||
volumes:
|
||||
- ./data/state:/data/state
|
||||
- output_data:/out
|
||||
# Development mounts (read-only to prevent accidental writes)
|
||||
- ./src:/app/src:ro
|
||||
- ./scripts:/app/scripts:ro
|
||||
138
docker-compose.yml
Normal file
138
docker-compose.yml
Normal file
@@ -0,0 +1,138 @@
|
||||
# MeshCore Stats - Docker Compose Configuration
|
||||
#
|
||||
# Production deployment using published container image.
|
||||
# For local development, use: docker compose -f docker-compose.yml -f docker-compose.development.yml up
|
||||
#
|
||||
# Prerequisites:
|
||||
# 1. Copy meshcore.conf.example to meshcore.conf and configure
|
||||
# 2. Ensure your user has access to the serial device (dialout group)
|
||||
# 3. Create data directory: mkdir -p ./data/state
|
||||
|
||||
services:
|
||||
# ==========================================================================
|
||||
# MeshCore Stats - Data collection and rendering
|
||||
# ==========================================================================
|
||||
meshcore-stats:
|
||||
image: ghcr.io/jorijn/meshcore-stats:0.3.0 # x-release-please-version
|
||||
container_name: meshcore-stats
|
||||
restart: unless-stopped
|
||||
|
||||
# Load configuration from meshcore.conf
|
||||
env_file:
|
||||
- meshcore.conf
|
||||
|
||||
# Serial device for companion node communication
|
||||
devices:
|
||||
# Update to match your serial device (e.g., /dev/ttyACM0, /dev/cu.usbserial-*)
|
||||
- /dev/ttyUSB0:/dev/ttyUSB0:rw
|
||||
|
||||
volumes:
|
||||
# Persistent storage for SQLite database and circuit breaker state
|
||||
- ./data/state:/data/state
|
||||
# Shared volume for generated static site (used by nginx)
|
||||
- output_data:/out
|
||||
|
||||
# Run as meshmon user (UID 1000)
|
||||
user: "1000:1000"
|
||||
|
||||
# Add dialout group for serial port access
|
||||
group_add:
|
||||
- dialout
|
||||
|
||||
# Security hardening
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:noexec,nosuid,size=64m
|
||||
|
||||
# Resource limits
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "1.0"
|
||||
memory: 512M
|
||||
reservations:
|
||||
cpus: "0.1"
|
||||
memory: 128M
|
||||
|
||||
# Logging limits to prevent disk exhaustion
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# Health check
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import sqlite3; sqlite3.connect('/data/state/metrics.db').execute('SELECT 1')"]
|
||||
interval: 5m
|
||||
timeout: 30s
|
||||
start_period: 60s
|
||||
retries: 3
|
||||
|
||||
# ==========================================================================
|
||||
# nginx - Static site server
|
||||
# ==========================================================================
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: meshcore-stats-nginx
|
||||
restart: unless-stopped
|
||||
|
||||
# Run as nginx user (UID 101 in Alpine nginx image)
|
||||
user: "101:101"
|
||||
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
volumes:
|
||||
# Mount generated static site from meshcore-stats container
|
||||
- output_data:/usr/share/nginx/html:ro
|
||||
# Custom nginx configuration
|
||||
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
|
||||
# Security hardening
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
# NET_BIND_SERVICE not needed for port 8080 (unprivileged)
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /var/cache/nginx:noexec,nosuid,size=16m
|
||||
- /var/run:noexec,nosuid,size=1m
|
||||
|
||||
# Resource limits
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "0.5"
|
||||
memory: 64M
|
||||
reservations:
|
||||
cpus: "0.05"
|
||||
memory: 16M
|
||||
|
||||
# Logging limits
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# Health check
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
start_period: 5s
|
||||
retries: 3
|
||||
|
||||
depends_on:
|
||||
meshcore-stats:
|
||||
condition: service_healthy
|
||||
|
||||
# Named volume for sharing generated site between containers
|
||||
volumes:
|
||||
output_data:
|
||||
67
docker/nginx.conf
Normal file
67
docker/nginx.conf
Normal file
@@ -0,0 +1,67 @@
|
||||
# nginx configuration for MeshCore Stats static site
|
||||
# This file is used by the nginx container in docker-compose.yml
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index day.html index.html;
|
||||
|
||||
# UTF-8 charset for all text files
|
||||
charset utf-8;
|
||||
charset_types text/plain text/css text/javascript application/json image/svg+xml;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/javascript application/json image/svg+xml;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# HTML, JSON, TXT files - no cache (frequently updated)
|
||||
location ~* \.(html|json|txt)$ {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
add_header Expires "0" always;
|
||||
# Re-add security headers (add_header in location blocks replaces parent)
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
}
|
||||
|
||||
# PNG files - no cache (charts are regenerated frequently)
|
||||
location ~* \.png$ {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
add_header Expires "0" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
}
|
||||
|
||||
# CSS, JS, SVG files - short cache (5 minutes)
|
||||
location ~* \.(css|js|svg)$ {
|
||||
expires 5m;
|
||||
add_header Cache-Control "public, max-age=300" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
}
|
||||
|
||||
# Default location
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "OK\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
55
docker/ofelia.ini
Normal file
55
docker/ofelia.ini
Normal file
@@ -0,0 +1,55 @@
|
||||
# Ofelia Job Scheduler Configuration
|
||||
# https://github.com/mcuadros/ofelia
|
||||
#
|
||||
# This file defines the cron-like schedule for all MeshCore Stats tasks.
|
||||
# Jobs run inside the same container (job-local).
|
||||
|
||||
[global]
|
||||
# Save last run state for job status
|
||||
save = true
|
||||
|
||||
# =============================================================================
|
||||
# Data Collection Jobs
|
||||
# =============================================================================
|
||||
|
||||
[job-local "collect-companion"]
|
||||
# Collect metrics from companion node (USB serial)
|
||||
schedule = @every 1m
|
||||
command = python /app/scripts/collect_companion.py
|
||||
no-overlap = true
|
||||
|
||||
[job-local "collect-repeater"]
|
||||
# Collect metrics from repeater node (via LoRa)
|
||||
# Offset by 1 second to avoid USB serial conflicts with companion collection
|
||||
schedule = 1 1,16,31,46 * * * *
|
||||
command = python /app/scripts/collect_repeater.py
|
||||
no-overlap = true
|
||||
|
||||
# =============================================================================
|
||||
# Rendering Jobs
|
||||
# =============================================================================
|
||||
|
||||
[job-local "render-charts"]
|
||||
# Generate SVG charts from database
|
||||
schedule = @every 5m
|
||||
command = python /app/scripts/render_charts.py
|
||||
|
||||
[job-local "render-site"]
|
||||
# Generate static HTML site
|
||||
schedule = @every 5m
|
||||
command = python /app/scripts/render_site.py
|
||||
|
||||
[job-local "render-reports"]
|
||||
# Generate monthly/yearly statistics reports
|
||||
schedule = @daily
|
||||
command = python /app/scripts/render_reports.py
|
||||
|
||||
# =============================================================================
|
||||
# Maintenance Jobs
|
||||
# =============================================================================
|
||||
|
||||
[job-local "db-maintenance"]
|
||||
# Database VACUUM and ANALYZE for optimal performance
|
||||
# Runs at 3 AM on the 1st of each month
|
||||
schedule = 0 3 1 * *
|
||||
command = python -c "import sqlite3; db=sqlite3.connect('/data/state/metrics.db'); db.execute('VACUUM'); db.execute('ANALYZE'); db.close(); print('Database maintenance complete')"
|
||||
@@ -9,6 +9,11 @@
|
||||
"type": "generic",
|
||||
"path": "src/meshmon/__init__.py",
|
||||
"glob": false
|
||||
},
|
||||
{
|
||||
"type": "generic",
|
||||
"path": "docker-compose.yml",
|
||||
"glob": false
|
||||
}
|
||||
],
|
||||
"changelog-sections": [
|
||||
|
||||
Reference in New Issue
Block a user