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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ DJANGO_DEBUG=true
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
DJANGO_CSRF_TRUSTED_ORIGINS=
DJANGO_TIME_ZONE=UTC

# Leave empty for local sqlite; Compose overrides with Postgres.
DATABASE_URL=
DJANGO_SQLITE_PATH=db.sqlite3

CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
Expand All @@ -11,5 +14,8 @@ APOLLO_DEMO_USERNAME=apollo
APOLLO_DEMO_PASSWORD=apollo
APOLLO_SEED_ON_STARTUP=false

# Due-check worker loop (seconds)
CHECK_INTERVAL_SECONDS=60

SESSION_COOKIE_SECURE=false
CSRF_COOKIE_SECURE=false
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@ jobs:
backend:
name: Backend tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: apollo
POSTGRES_USER: apollo
POSTGRES_PASSWORD: apollo
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U apollo -d apollo"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgres://apollo:apollo@localhost:5432/apollo
steps:
- uses: actions/checkout@v4

Expand Down Expand Up @@ -63,3 +79,6 @@ jobs:

- name: Build image
run: docker build -t apollo:ci .

- name: Validate compose file
run: docker compose config -q
10 changes: 6 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,19 @@ COPY config ./config
COPY monitoring ./monitoring
COPY --from=frontend /app/frontend/dist ./frontend/dist
COPY docker/entrypoint.sh /entrypoint.sh
COPY docker/worker.sh /worker.sh
COPY docker/wait_for_db.py /wait_for_db.py

RUN chmod +x /entrypoint.sh \
RUN chmod +x /entrypoint.sh /worker.sh \
&& mkdir -p /data

ENV DJANGO_DEBUG=false \
DJANGO_ALLOWED_HOSTS=* \
DJANGO_SQLITE_PATH=/data/db.sqlite3 \
APOLLO_SEED_ON_STARTUP=true
APOLLO_SEED_ON_STARTUP=true \
CHECK_INTERVAL_SECONDS=60

EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD curl -fsS http://127.0.0.1:8000/api/health/ || exit 1

ENTRYPOINT ["/entrypoint.sh"]
77 changes: 38 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ Django + React API Health Monitor (monolith).

- **Backend:** Django 6 + DRF + httpx + Token auth + WhiteNoise + Gunicorn (Poetry)
- **Frontend:** React + Vite + Redux Toolkit (pnpm)
- **Ops:** Docker, docker-compose, GitHub Actions CI, `.env` configuration
- **Features:** scheduled checks, webhook alerts, uptime dashboard
- **Data:** SQLite (local default) or **PostgreSQL** via `DATABASE_URL`
- **Ops:** Docker Compose (`db` + `web` + `worker`), GitHub Actions CI
- **Features:** due checks, webhook alerts, uptime dashboard, background check worker

## Quick start (local)
## Quick start (local / SQLite)

```bash
cp .env.example .env
Expand All @@ -18,84 +19,82 @@ poetry run python manage.py migrate
poetry run python manage.py seed

pnpm --dir frontend install
```

Run API + Vite:

```bash
poetry run python manage.py runserver
pnpm --dir frontend dev
```

Open http://localhost:5173 and sign in with **`apollo` / `apollo`**.
Open http://localhost:5173 — login **`apollo` / `apollo`**.

## Docker
## Docker (Postgres + web + worker)

```bash
cp .env.example .env
# set DJANGO_SECRET_KEY and DJANGO_DEBUG=false for real deploys
# set a real DJANGO_SECRET_KEY for deploys
docker compose up --build
```

App: http://localhost:8000 (SPA + API in one container)
Login: `apollo` / `apollo` (seeded on startup when `APOLLO_SEED_ON_STARTUP=true`)
Services:

| Service | Role |
|---------|------|
| `db` | PostgreSQL 16 |
| `web` | Gunicorn API + SPA |
| `worker` | Loop: `check_endpoints --due` every `CHECK_INTERVAL_SECONDS` |

Useful:
App: http://localhost:8000
Login: `apollo` / `apollo` (seeded by web when `APOLLO_SEED_ON_STARTUP=true`)

```bash
docker compose logs -f web
docker compose logs -f worker
docker compose down
```

## Environment

See [`.env.example`](.env.example). Important keys:
See [`.env.example`](.env.example).

| Variable | Purpose |
|----------|---------|
| `DJANGO_SECRET_KEY` | Django secret |
| `DJANGO_DEBUG` | `true` / `false` |
| `DJANGO_ALLOWED_HOSTS` | Comma-separated hosts |
| `DJANGO_SQLITE_PATH` | SQLite file path (`/data/db.sqlite3` in Docker) |
| `CORS_ALLOWED_ORIGINS` | Browser origins allowed to call the API |
| `APOLLO_DEMO_USERNAME` / `APOLLO_DEMO_PASSWORD` | Seeded demo user |
| `APOLLO_SEED_ON_STARTUP` | Run `seed` on container boot |
| `DATABASE_URL` | Postgres URL (empty = SQLite) |
| `DJANGO_SQLITE_PATH` | SQLite path when `DATABASE_URL` is empty |
| `CHECK_INTERVAL_SECONDS` | Worker loop interval (default 60) |
| `APOLLO_SEED_ON_STARTUP` | Seed demo user/endpoints on web boot |
| `DJANGO_SECRET_KEY` / `DJANGO_DEBUG` / `DJANGO_ALLOWED_HOSTS` | Django core |

## Auth

API uses **Token authentication** (`Authorization: Token <key>`).
Token auth (`Authorization: Token <key>`). Public: `/api/health/`, `/api/auth/login/`.

| Method | Path | Auth |
|--------|------|------|
| POST | `/api/auth/login/` | public |
| POST | `/api/auth/logout/` | token |
| GET | `/api/auth/me/` | token |
| GET | `/api/health/` | public |
| GET | `/api/dashboard/` | token |
| * | `/api/endpoints/…` | token |
## Scheduling

## Scheduling (cron / host)
**Docker:** the `worker` service runs due checks automatically.

**Host cron (SQLite/Poetry):**

```bash
* * * * * cd /path/to/Apollo && poetry run python manage.py check_endpoints --due
```

Inside Docker you can add a second service or host cron hitting the same volume/DB.
**One-shot / debug:**

```bash
poetry run python manage.py run_check_worker --once
poetry run python manage.py run_check_worker --interval 30
```

## CI

GitHub Actions (`.github/workflows/ci.yml`) runs:
`.github/workflows/ci.yml`:

1. Poetry + pytest
1. Pytest against Postgres service
2. pnpm build
3. `docker build`
3. `docker build` + `docker compose config`

## Useful commands

```bash
poetry run pytest
poetry run python manage.py check_endpoints --due
pnpm --dir frontend build
poetry run python manage.py collectstatic --noinput
poetry run gunicorn config.wsgi:application --bind 0.0.0.0:8000
docker compose up --build
```
25 changes: 19 additions & 6 deletions config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,25 @@ def env_list(name: str, default: str = "") -> list[str]:

WSGI_APPLICATION = "config.wsgi.application"

sqlite_path = env("DJANGO_SQLITE_PATH", str(BASE_DIR / "db.sqlite3"))
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": sqlite_path,
DATABASE_URL = env("DATABASE_URL")
if DATABASE_URL:
import dj_database_url

DATABASES = {
"default": dj_database_url.parse(
DATABASE_URL,
conn_max_age=600,
conn_health_checks=True,
)
}
else:
sqlite_path = env("DJANGO_SQLITE_PATH", str(BASE_DIR / "db.sqlite3"))
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": sqlite_path,
}
}
}

AUTH_PASSWORD_VALIDATORS = [
{
Expand Down Expand Up @@ -153,6 +165,7 @@ def env_list(name: str, default: str = "") -> list[str]:
APOLLO_DEMO_USERNAME = env("APOLLO_DEMO_USERNAME", "apollo") or "apollo"
APOLLO_DEMO_PASSWORD = env("APOLLO_DEMO_PASSWORD", "apollo") or "apollo"
APOLLO_SEED_ON_STARTUP = env_bool("APOLLO_SEED_ON_STARTUP", False)
CHECK_INTERVAL_SECONDS = int(env("CHECK_INTERVAL_SECONDS", "60") or "60")

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", not DEBUG)
Expand Down
16 changes: 16 additions & 0 deletions config/test_settings_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,19 @@ def test_whitenoise_is_enabled() -> None:
assert "whitenoise.storage.CompressedStaticFilesStorage" in str(
settings.STORAGES["staticfiles"]["BACKEND"]
)


def test_check_interval_setting() -> None:
from django.conf import settings

assert isinstance(settings.CHECK_INTERVAL_SECONDS, int)
assert settings.CHECK_INTERVAL_SECONDS >= 1


def test_database_url_parsing() -> None:
import dj_database_url

config = dj_database_url.parse("postgres://apollo:apollo@db:5432/apollo")
assert config["ENGINE"].endswith("postgresql")
assert config["NAME"] == "apollo"
assert config["HOST"] == "db"
43 changes: 39 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: apollo
POSTGRES_USER: apollo
POSTGRES_PASSWORD: apollo
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U apollo -d apollo"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped

web:
build: .
ports:
Expand All @@ -8,12 +23,32 @@ services:
environment:
DJANGO_DEBUG: "false"
DJANGO_ALLOWED_HOSTS: "*"
DJANGO_SQLITE_PATH: /data/db.sqlite3
DATABASE_URL: postgres://apollo:apollo@db:5432/apollo
APOLLO_SEED_ON_STARTUP: "${APOLLO_SEED_ON_STARTUP:-true}"
CORS_ALLOWED_ORIGINS: "${CORS_ALLOWED_ORIGINS:-http://localhost:8000,http://127.0.0.1:8000}"
volumes:
- apollo_data:/data
CHECK_INTERVAL_SECONDS: "${CHECK_INTERVAL_SECONDS:-60}"
depends_on:
db:
condition: service_healthy
restart: unless-stopped

worker:
build: .
command: ["/worker.sh"]
env_file:
- .env
environment:
DJANGO_DEBUG: "false"
DJANGO_ALLOWED_HOSTS: "*"
DATABASE_URL: postgres://apollo:apollo@db:5432/apollo
APOLLO_SEED_ON_STARTUP: "false"
CHECK_INTERVAL_SECONDS: "${CHECK_INTERVAL_SECONDS:-60}"
depends_on:
db:
condition: service_healthy
web:
condition: service_started
restart: unless-stopped

volumes:
apollo_data:
postgres_data:
3 changes: 3 additions & 0 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
#!/bin/sh
set -eu

echo "Waiting for database..."
python /wait_for_db.py

echo "Running migrations..."
python manage.py migrate --noinput

Expand Down
49 changes: 49 additions & 0 deletions docker/wait_for_db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env python
"""Wait until the configured database accepts connections."""

from __future__ import annotations

import os
import sys
import time


def main() -> int:
database_url = os.environ.get("DATABASE_URL", "").strip()
if not database_url:
print("No DATABASE_URL set; skipping DB wait (sqlite mode).")
return 0

import dj_database_url
import psycopg

config = dj_database_url.parse(database_url)
if not config.get("ENGINE", "").endswith("postgresql"):
print(f"DATABASE_URL engine is {config.get('ENGINE')}; skipping wait.")
return 0

retries = int(os.environ.get("DB_WAIT_RETRIES", "30"))
delay = float(os.environ.get("DB_WAIT_DELAY", "2"))

conninfo = (
f"host={config['HOST']} port={config.get('PORT') or 5432} "
f"dbname={config['NAME']} user={config['USER']} "
f"password={config.get('PASSWORD') or ''}"
)

for attempt in range(1, retries + 1):
try:
with psycopg.connect(conninfo, connect_timeout=3) as connection:
connection.execute("SELECT 1")
print(f"Database is ready (attempt {attempt}).")
return 0
except Exception as exc: # noqa: BLE001 - retry until timeout
print(f"Waiting for database ({attempt}/{retries}): {exc}")
time.sleep(delay)

print("Database did not become ready in time.", file=sys.stderr)
return 1


if __name__ == "__main__":
raise SystemExit(main())
8 changes: 8 additions & 0 deletions docker/worker.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/bin/sh
set -eu

echo "Waiting for database..."
python /wait_for_db.py

echo "Starting check worker (interval=${CHECK_INTERVAL_SECONDS:-60}s)..."
exec python manage.py run_check_worker --interval "${CHECK_INTERVAL_SECONDS:-60}"
Loading
Loading