feat: complete CLI, Docker & deployment docs (Step 10.3)

- Add comprehensive CLI tests for seed and serve commands
- Create README.md with development setup, deployment guide,
  and environment variable reference
- Mark Step 10.3 as complete in PLAN.md

This completes the final implementation step.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-31 18:21:14 +00:00
parent 49871f60c5
commit c6a87e35d4
4 changed files with 578 additions and 6 deletions

12
PLAN.md
View File

@@ -395,12 +395,12 @@ tests verify implementation consistency, not exact spec values.
- [x] **Commit checkpoint** (229842f)
### Step 10.3: CLI, Docker & Deployment
- [ ] Complete CLI: serve, seed, migrate commands
- [ ] Update flake.nix for Docker image build
- [ ] Create docker.nix
- [ ] Document deployment configuration
- [ ] Write tests: CLI commands work
- [ ] **Final commit**
- [x] Complete CLI: serve, seed, migrate commands
- [x] Update flake.nix for Docker image build
- [x] Create docker.nix
- [x] Document deployment configuration (README.md)
- [x] Write tests: CLI commands work (test_cli_seed.py, test_cli_serve.py)
- [x] **Final commit**
---

143
README.md Normal file
View File

@@ -0,0 +1,143 @@
# AnimalTrack
Event-sourced animal tracking for poultry farm management. Built with FastHTML, MonsterUI, and SQLite.
## Features
- **Egg Collection** - Quick capture of daily egg counts by location
- **Feed Tracking** - Record purchases and distribution with cost tracking
- **Animal Registry** - Track cohorts, movements, and lifecycle events
- **Historical Queries** - Point-in-time resolution using interval projections
- **Event Sourcing** - Full audit trail with edit/delete support
## Development Setup
### Prerequisites
- [Nix](https://nixos.org/download.html) with flakes enabled
- [direnv](https://direnv.net/) (optional but recommended)
### Quick Start
```bash
# Clone the repo
git clone <repo-url>
cd animaltrack
# Enter dev environment
direnv allow # or: nix develop
# Run the server
animaltrack serve
```
The server starts at http://localhost:3366 (3366 = EGG in leetspeak).
### CLI Commands
```bash
# Run database migrations
animaltrack migrate
# Load seed data (users, locations, species, products, feed types)
animaltrack seed
# Start the web server
animaltrack serve [--port 3366] [--host 0.0.0.0]
# Create a new migration
animaltrack create-migration "add users table"
```
### Running Tests
```bash
pytest tests/ -v
```
## Deployment
### Docker
Build the Docker image using Nix:
```bash
nix build .#dockerImage
docker load < result
```
Run the container:
```bash
docker run -d \
-p 8080:3366 \
-v /data/animaltrack:/var/lib/animaltrack \
-e CSRF_SECRET="your-secret-here" \
-e TRUSTED_PROXY_IPS="10.0.0.1,10.0.0.2" \
gitea.v.paler.net/ppetru/animaltrack:latest
```
### Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `CSRF_SECRET` | **Yes** | - | Secret for CSRF token generation |
| `DB_PATH` | No | `animaltrack.db` | SQLite database path |
| `PORT` | No | `3366` | Server port |
| `AUTH_HEADER_NAME` | No | `X-Oidc-Username` | Header containing username from reverse proxy |
| `TRUSTED_PROXY_IPS` | No | (empty) | Comma-separated IPs that can set auth headers |
| `CSRF_COOKIE_NAME` | No | `csrf_token` | CSRF cookie name |
| `SEED_ON_START` | No | `false` | Run seeds on startup |
| `LOG_LEVEL` | No | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) |
| `METRICS_ENABLED` | No | `true` | Enable Prometheus metrics at /metrics |
| `DEV_MODE` | No | `false` | Bypasses auth, sets default user for development |
| `BASE_PATH` | No | `/` | URL base path for reverse proxy setups |
### Reverse Proxy Configuration
AnimalTrack expects authentication to be handled by a reverse proxy (e.g., Authelia, Authentik, oauth2-proxy). The proxy should:
1. Authenticate users via OIDC/OAuth2
2. Forward the username in `X-Oidc-Username` header (configurable via `AUTH_HEADER_NAME`)
3. Be listed in `TRUSTED_PROXY_IPS` to be trusted
Example Caddy configuration:
```
animaltrack.example.com {
forward_auth authelia:9091 {
uri /api/verify?rd=https://auth.example.com
copy_headers Remote-User Remote-Groups Remote-Email
header_up X-Oidc-Username {http.auth.resp.Remote-User}
}
reverse_proxy animaltrack:3366
}
```
### Data Persistence
Mount a volume to `/var/lib/animaltrack` for the SQLite database:
```bash
-v /path/on/host:/var/lib/animaltrack
```
The database file will be created at `$DB_PATH` (default: `/var/lib/animaltrack/animaltrack.db` in Docker).
### Health Check
- `GET /healthz` - Returns 200 if database is writable, 503 otherwise
- `GET /metrics` - Prometheus metrics (when `METRICS_ENABLED=true`)
## Architecture
- **Event Sourcing** - All state changes are events. Events are immutable.
- **Projections** - Materialized views updated synchronously in transactions.
- **Interval Tables** - Track animal locations, tags, and attributes over time.
- **ULID IDs** - Time-ordered, sortable, unique identifiers.
- **Integer Timestamps** - Milliseconds since Unix epoch for all timestamps.
- **Integer Money** - All prices stored as cents for precision.
## License
Proprietary - All rights reserved.

241
tests/test_cli_seed.py Normal file
View File

@@ -0,0 +1,241 @@
# ABOUTME: End-to-end tests for seed CLI command.
# ABOUTME: Tests seeding reference data via subprocess.
import os
import subprocess
import sys
from pathlib import Path
from animaltrack.db import get_db
from animaltrack.repositories import (
FeedTypeRepository,
LocationRepository,
ProductRepository,
SpeciesRepository,
UserRepository,
)
# Get the project root directory (parent of tests/)
PROJECT_ROOT = Path(__file__).parent.parent
class TestSeedCLI:
"""End-to-end tests for seed command."""
def test_seed_command_success(self, tmp_path):
"""Should seed data via CLI and exit 0."""
db_path = tmp_path / "test.db"
env = os.environ.copy()
env["DB_PATH"] = str(db_path)
env["CSRF_SECRET"] = "test-secret-for-csrf"
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
result = subprocess.run(
[sys.executable, "-m", "animaltrack.cli", "seed"],
capture_output=True,
text=True,
env=env,
cwd=str(PROJECT_ROOT),
)
assert result.returncode == 0, f"stdout: {result.stdout}, stderr: {result.stderr}"
assert "seed data loaded" in result.stdout.lower()
def test_seed_creates_users(self, tmp_path):
"""Should create expected users."""
db_path = tmp_path / "test.db"
env = os.environ.copy()
env["DB_PATH"] = str(db_path)
env["CSRF_SECRET"] = "test-secret-for-csrf"
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
result = subprocess.run(
[sys.executable, "-m", "animaltrack.cli", "seed"],
capture_output=True,
text=True,
env=env,
cwd=str(PROJECT_ROOT),
)
assert result.returncode == 0, f"stdout: {result.stdout}, stderr: {result.stderr}"
# Verify users were created
db = get_db(str(db_path))
repo = UserRepository(db)
users = repo.list_all()
usernames = {u.username for u in users}
assert "ppetru" in usernames
assert "ines" in usernames
assert "guest" in usernames
def test_seed_creates_locations(self, tmp_path):
"""Should create expected locations."""
db_path = tmp_path / "test.db"
env = os.environ.copy()
env["DB_PATH"] = str(db_path)
env["CSRF_SECRET"] = "test-secret-for-csrf"
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
result = subprocess.run(
[sys.executable, "-m", "animaltrack.cli", "seed"],
capture_output=True,
text=True,
env=env,
cwd=str(PROJECT_ROOT),
)
assert result.returncode == 0, f"stdout: {result.stdout}, stderr: {result.stderr}"
# Verify locations were created
db = get_db(str(db_path))
repo = LocationRepository(db)
locations = repo.list_all()
names = {loc.name for loc in locations}
assert "Strip 1" in names
assert "Strip 4" in names
assert "Nursery 1" in names
assert "Nursery 4" in names
assert len(locations) == 8
def test_seed_creates_species(self, tmp_path):
"""Should create expected species."""
db_path = tmp_path / "test.db"
env = os.environ.copy()
env["DB_PATH"] = str(db_path)
env["CSRF_SECRET"] = "test-secret-for-csrf"
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
result = subprocess.run(
[sys.executable, "-m", "animaltrack.cli", "seed"],
capture_output=True,
text=True,
env=env,
cwd=str(PROJECT_ROOT),
)
assert result.returncode == 0, f"stdout: {result.stdout}, stderr: {result.stderr}"
# Verify species were created
db = get_db(str(db_path))
repo = SpeciesRepository(db)
species_list = repo.list_all()
codes = {s.code for s in species_list}
assert "duck" in codes
assert "goose" in codes
assert "sheep" in codes
def test_seed_creates_products(self, tmp_path):
"""Should create expected products."""
db_path = tmp_path / "test.db"
env = os.environ.copy()
env["DB_PATH"] = str(db_path)
env["CSRF_SECRET"] = "test-secret-for-csrf"
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
result = subprocess.run(
[sys.executable, "-m", "animaltrack.cli", "seed"],
capture_output=True,
text=True,
env=env,
cwd=str(PROJECT_ROOT),
)
assert result.returncode == 0, f"stdout: {result.stdout}, stderr: {result.stderr}"
# Verify products were created
db = get_db(str(db_path))
repo = ProductRepository(db)
products = repo.list_all()
codes = {p.code for p in products}
assert "egg.duck" in codes
assert "meat" in codes
assert "offal" in codes
assert "fat" in codes
assert "bones" in codes
assert "feathers" in codes
assert "down" in codes
def test_seed_creates_feed_types(self, tmp_path):
"""Should create expected feed types."""
db_path = tmp_path / "test.db"
env = os.environ.copy()
env["DB_PATH"] = str(db_path)
env["CSRF_SECRET"] = "test-secret-for-csrf"
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
result = subprocess.run(
[sys.executable, "-m", "animaltrack.cli", "seed"],
capture_output=True,
text=True,
env=env,
cwd=str(PROJECT_ROOT),
)
assert result.returncode == 0, f"stdout: {result.stdout}, stderr: {result.stderr}"
# Verify feed types were created
db = get_db(str(db_path))
repo = FeedTypeRepository(db)
feed_types = repo.list_all()
codes = {f.code for f in feed_types}
assert "starter" in codes
assert "grower" in codes
assert "layer" in codes
def test_seed_is_idempotent(self, tmp_path):
"""Running seed twice should produce same result."""
db_path = tmp_path / "test.db"
env = os.environ.copy()
env["DB_PATH"] = str(db_path)
env["CSRF_SECRET"] = "test-secret-for-csrf"
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
# Run seed first time
result1 = subprocess.run(
[sys.executable, "-m", "animaltrack.cli", "seed"],
capture_output=True,
text=True,
env=env,
cwd=str(PROJECT_ROOT),
)
assert result1.returncode == 0, f"stdout: {result1.stdout}, stderr: {result1.stderr}"
# Run seed second time
result2 = subprocess.run(
[sys.executable, "-m", "animaltrack.cli", "seed"],
capture_output=True,
text=True,
env=env,
cwd=str(PROJECT_ROOT),
)
assert result2.returncode == 0, f"stdout: {result2.stdout}, stderr: {result2.stderr}"
# Verify counts haven't doubled
db = get_db(str(db_path))
user_repo = UserRepository(db)
assert len(user_repo.list_all()) == 3
location_repo = LocationRepository(db)
assert len(location_repo.list_all()) == 8
species_repo = SpeciesRepository(db)
assert len(species_repo.list_all()) == 3
product_repo = ProductRepository(db)
assert len(product_repo.list_all()) == 7
feed_repo = FeedTypeRepository(db)
assert len(feed_repo.list_all()) == 3

188
tests/test_cli_serve.py Normal file
View File

@@ -0,0 +1,188 @@
# ABOUTME: End-to-end tests for serve CLI command.
# ABOUTME: Tests server setup and argument handling via mocked uvicorn.
import os
import subprocess
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from animaltrack.cli import main
# Get the project root directory (parent of tests/)
PROJECT_ROOT = Path(__file__).parent.parent
class TestServeCLI:
"""Tests for serve command."""
def test_serve_runs_migrations_first(self, tmp_path, monkeypatch):
"""Serve should run migrations before starting server."""
db_path = tmp_path / "test.db"
monkeypatch.setenv("DB_PATH", str(db_path))
monkeypatch.setenv("CSRF_SECRET", "test-secret-for-csrf")
# Mock uvicorn.run to prevent server from starting
with patch("uvicorn.run") as mock_uvicorn:
with patch("animaltrack.migrations.run_migrations") as mock_migrations:
mock_migrations.return_value = True
monkeypatch.setattr("sys.argv", ["animaltrack", "serve"])
main()
# Verify migrations were called
mock_migrations.assert_called_once()
# Verify uvicorn was called after migrations
mock_uvicorn.assert_called_once()
def test_serve_creates_app(self, tmp_path, monkeypatch):
"""Serve should create the app before starting."""
db_path = tmp_path / "test.db"
monkeypatch.setenv("DB_PATH", str(db_path))
monkeypatch.setenv("CSRF_SECRET", "test-secret-for-csrf")
with patch("uvicorn.run") as mock_uvicorn:
with patch("animaltrack.migrations.run_migrations") as mock_migrations:
mock_migrations.return_value = True
with patch("animaltrack.web.app.create_app") as mock_create_app:
mock_app = MagicMock()
mock_create_app.return_value = (mock_app, None)
monkeypatch.setattr("sys.argv", ["animaltrack", "serve"])
main()
# Verify create_app was called
mock_create_app.assert_called_once()
# Verify uvicorn got the app
mock_uvicorn.assert_called_once()
call_kwargs = mock_uvicorn.call_args
assert call_kwargs[0][0] == mock_app
def test_serve_uses_default_port(self, tmp_path, monkeypatch):
"""Serve should use port 3366 by default."""
db_path = tmp_path / "test.db"
monkeypatch.setenv("DB_PATH", str(db_path))
monkeypatch.setenv("CSRF_SECRET", "test-secret-for-csrf")
with patch("uvicorn.run") as mock_uvicorn:
with patch("animaltrack.migrations.run_migrations") as mock_migrations:
mock_migrations.return_value = True
with patch("animaltrack.web.app.create_app") as mock_create_app:
mock_app = MagicMock()
mock_create_app.return_value = (mock_app, None)
monkeypatch.setattr("sys.argv", ["animaltrack", "serve"])
main()
call_kwargs = mock_uvicorn.call_args
assert call_kwargs[1]["port"] == 3366
def test_serve_custom_port(self, tmp_path, monkeypatch):
"""Serve should accept custom port."""
db_path = tmp_path / "test.db"
monkeypatch.setenv("DB_PATH", str(db_path))
monkeypatch.setenv("CSRF_SECRET", "test-secret-for-csrf")
with patch("uvicorn.run") as mock_uvicorn:
with patch("animaltrack.migrations.run_migrations") as mock_migrations:
mock_migrations.return_value = True
with patch("animaltrack.web.app.create_app") as mock_create_app:
mock_app = MagicMock()
mock_create_app.return_value = (mock_app, None)
monkeypatch.setattr("sys.argv", ["animaltrack", "serve", "--port", "8080"])
main()
call_kwargs = mock_uvicorn.call_args
assert call_kwargs[1]["port"] == 8080
def test_serve_custom_host(self, tmp_path, monkeypatch):
"""Serve should accept custom host."""
db_path = tmp_path / "test.db"
monkeypatch.setenv("DB_PATH", str(db_path))
monkeypatch.setenv("CSRF_SECRET", "test-secret-for-csrf")
with patch("uvicorn.run") as mock_uvicorn:
with patch("animaltrack.migrations.run_migrations") as mock_migrations:
mock_migrations.return_value = True
with patch("animaltrack.web.app.create_app") as mock_create_app:
mock_app = MagicMock()
mock_create_app.return_value = (mock_app, None)
monkeypatch.setattr("sys.argv", ["animaltrack", "serve", "--host", "127.0.0.1"])
main()
call_kwargs = mock_uvicorn.call_args
assert call_kwargs[1]["host"] == "127.0.0.1"
def test_serve_uses_default_host(self, tmp_path, monkeypatch):
"""Serve should use 0.0.0.0 as default host."""
db_path = tmp_path / "test.db"
monkeypatch.setenv("DB_PATH", str(db_path))
monkeypatch.setenv("CSRF_SECRET", "test-secret-for-csrf")
with patch("uvicorn.run") as mock_uvicorn:
with patch("animaltrack.migrations.run_migrations") as mock_migrations:
mock_migrations.return_value = True
with patch("animaltrack.web.app.create_app") as mock_create_app:
mock_app = MagicMock()
mock_create_app.return_value = (mock_app, None)
monkeypatch.setattr("sys.argv", ["animaltrack", "serve"])
main()
call_kwargs = mock_uvicorn.call_args
assert call_kwargs[1]["host"] == "0.0.0.0"
def test_serve_exits_on_migration_failure(self, tmp_path, monkeypatch):
"""Serve should exit 1 if migrations fail."""
db_path = tmp_path / "test.db"
monkeypatch.setenv("DB_PATH", str(db_path))
monkeypatch.setenv("CSRF_SECRET", "test-secret-for-csrf")
with patch("uvicorn.run") as mock_uvicorn:
with patch("animaltrack.migrations.run_migrations") as mock_migrations:
mock_migrations.return_value = False
monkeypatch.setattr("sys.argv", ["animaltrack", "serve"])
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 1
# Uvicorn should not be called if migrations fail
mock_uvicorn.assert_not_called()
class TestServeHelpAndArgs:
"""Tests for serve command help and argument parsing."""
def test_serve_help(self):
"""Should display help for serve command."""
env = os.environ.copy()
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
result = subprocess.run(
[sys.executable, "-m", "animaltrack.cli", "serve", "--help"],
capture_output=True,
text=True,
env=env,
cwd=str(PROJECT_ROOT),
)
assert result.returncode == 0
assert "--port" in result.stdout
assert "--host" in result.stdout