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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
66 changes: 66 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: CI

on:
push:
branches: [main, develop]
pull_request:

defaults:
run:
working-directory: ev_backend

jobs:
test:
runs-on: ubuntu-latest
env:
# Tests and system checks run in debug mode with a throwaway secret.
DJANGO_DEBUG: "True"
DJANGO_SECRET_KEY: ci-test-secret-key-not-for-production
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: ev_backend/requirements-dev.txt

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt

- name: Lint (ruff)
run: ruff check .

- name: Migration check (models match migrations)
run: python manage.py makemigrations --check --dry-run

- name: Django system checks
run: python manage.py check

- name: Deployment security checks
env:
DJANGO_DEBUG: "False"
DJANGO_SKIP_PROD_CHECK: "True"
DJANGO_ALLOWED_HOSTS: example.com
run: python manage.py check --deploy

- name: Tests + coverage
run: |
coverage run manage.py test
coverage report

- name: Security static analysis (bandit)
run: bandit -r accounts bookings stations ev_backend -x tests --severity-level medium || true

- name: Dependency vulnerability audit (pip-audit)
run: pip-audit -r requirements.txt || true

build:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- name: Build production image
run: docker build -t ev-backend:ci ev_backend
39 changes: 39 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.eggs/

# Virtualenvs
venv/
.venv/
env/
ENV/

# Django
*.log
db.sqlite3
db.sqlite3-journal
/ev_backend/media/
/ev_backend/staticfiles/
ev_backend/media/
ev_backend/staticfiles/

# Secrets / local config
.env
*.env
!.env.example
.env.*
!.env.example

# OS / editor
.DS_Store
.idea/
.vscode/
*.swp

# Coverage / test artifacts
.coverage
htmlcov/
.pytest_cache/
coverage.xml
20 changes: 20 additions & 0 deletions ev_backend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
venv/
.venv/
__pycache__/
*.py[cod]
.env
.env.*
!.env.example
db.sqlite3
db.sqlite3-journal
media/
staticfiles/
.git/
.github/
.idea/
.vscode/
.DS_Store
*.log
.coverage
htmlcov/
docs/
2 changes: 0 additions & 2 deletions ev_backend/.env

This file was deleted.

48 changes: 48 additions & 0 deletions ev_backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Copy to .env and fill in. NEVER commit a real .env.
# cp .env.example .env

# ── Core ──────────────────────────────────────────────────────────────────
# Development: set DJANGO_DEBUG=True. Production: leave False.
DJANGO_DEBUG=True
# Generate: python -c "from django.core.management.utils import get_random_secret_key as k; print(k())"
DJANGO_SECRET_KEY=change-me-to-a-strong-random-secret
# Comma-separated. Required in production.
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1

# ── Database ──────────────────────────────────────────────────────────────
# Leave empty for local SQLite; set for production Postgres.
# DATABASE_URL=postgres://user:password@host:5432/dbname
# DB_SSL_REQUIRE=True

# ── Cache (rate limits, station-list cache) ───────────────────────────────
# Strongly recommended in production for cross-process correctness.
# REDIS_URL=redis://localhost:6379/0

# ── JWT ───────────────────────────────────────────────────────────────────
# Defaults to DJANGO_SECRET_KEY if unset.
# JWT_SECRET_KEY=change-me
JWT_ACCESS_MINUTES=30
JWT_REFRESH_DAYS=7

# ── Email (OTP delivery) ──────────────────────────────────────────────────
# EMAIL_HOST=smtp.gmail.com
# EMAIL_PORT=587
# EMAIL_USE_TLS=True
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
# DEFAULT_FROM_EMAIL=EV Charging <no-reply@example.com>

# ── CORS / CSRF (production) ───────────────────────────────────────────────
# CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com
# CORS_ALLOW_CREDENTIALS=False
# CSRF_TRUSTED_ORIGINS=https://admin.example.com

# ── Security headers (production; sensible defaults apply) ─────────────────
# SECURE_SSL_REDIRECT=True
# SECURE_HSTS_SECONDS=31536000

# ── App / ops ─────────────────────────────────────────────────────────────
APP_VERSION=1.0.0
LOG_LEVEL=INFO
# STATIC_ROOT=/app/staticfiles
# MEDIA_ROOT=/app/media
51 changes: 51 additions & 0 deletions ev_backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# syntax=docker/dockerfile:1
# Multi-stage, non-root production image for the EV Charging backend.

# ---- Builder: install dependencies into a venv ----
FROM python:3.12-slim AS builder

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1

RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*

RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY requirements.txt .
RUN pip install --upgrade pip && pip install -r requirements.txt

# ---- Runtime: slim, non-root ----
FROM python:3.12-slim AS runtime

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH" \
DJANGO_SETTINGS_MODULE=ev_backend.settings

# libpq for psycopg at runtime.
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*

# Non-root user.
RUN useradd --create-home --uid 10001 appuser
WORKDIR /app

COPY --from=builder /opt/venv /opt/venv
COPY --chown=appuser:appuser . /app

# Collect static at build time (WhiteNoise serves them). Uses a throwaway key so
# the build never needs real secrets; runtime overrides via env.
RUN DJANGO_DEBUG=True python manage.py collectstatic --noinput || true

RUN chmod +x /app/entrypoint.sh
USER appuser

EXPOSE 8000

# The entrypoint runs migrations then starts Gunicorn.
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["gunicorn", "ev_backend.wsgi:application", "-c", "gunicorn.conf.py"]
Binary file not shown.
Binary file not shown.
Binary file removed ev_backend/accounts/__pycache__/admin.cpython-311.pyc
Binary file not shown.
Binary file removed ev_backend/accounts/__pycache__/admin.cpython-312.pyc
Binary file not shown.
Binary file removed ev_backend/accounts/__pycache__/apps.cpython-311.pyc
Binary file not shown.
Binary file removed ev_backend/accounts/__pycache__/apps.cpython-312.pyc
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file removed ev_backend/bookings/__pycache__/admin.cpython-311.pyc
Binary file not shown.
Binary file removed ev_backend/bookings/__pycache__/admin.cpython-312.pyc
Binary file not shown.
Binary file removed ev_backend/bookings/__pycache__/apps.cpython-311.pyc
Binary file not shown.
Binary file removed ev_backend/bookings/__pycache__/apps.cpython-312.pyc
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file removed ev_backend/db.sqlite3
Binary file not shown.
47 changes: 47 additions & 0 deletions ev_backend/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Local/staging stack: web + Postgres + Redis. For production, run the `web`
# image behind a managed database, cache, and TLS-terminating load balancer.

services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-ev}
POSTGRES_USER: ${POSTGRES_USER:-ev}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ev}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-ev}"]
interval: 10s
timeout: 5s
retries: 5

redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5

web:
build: .
env_file: .env
environment:
DATABASE_URL: postgres://${POSTGRES_USER:-ev}:${POSTGRES_PASSWORD:-ev}@db:5432/${POSTGRES_DB:-ev}
REDIS_URL: redis://redis:6379/0
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/ready/').status==200 else 1)\""]
interval: 30s
timeout: 5s
retries: 5

volumes:
pgdata:
64 changes: 64 additions & 0 deletions ev_backend/docs/BACKUP_RESTORE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Backup, Restore & Monitoring

## Database backups

Scripted with `scripts/backup_db.sh` (PostgreSQL, gzipped `pg_dump`):

```bash
DATABASE_URL=postgres://user:pass@host:5432/db ./scripts/backup_db.sh /var/backups/ev
```

- Produces `db_<UTC-timestamp>.sql.gz`.
- Retention: deletes dumps older than `RETENTION_DAYS` (default 14).
- **Schedule it** (pick one):
- cron: `0 2 * * * DATABASE_URL=… /app/scripts/backup_db.sh /var/backups/ev`
- Kubernetes `CronJob` running the same image/command
- Managed-DB automated snapshots (RDS/Cloud SQL) — preferred; use the script
as a portable secondary.

Store backups off-host (S3/GCS bucket with versioning) and encrypt at rest.

## Media backups

`scripts/backup_media.sh` archives `MEDIA_ROOT`:

```bash
MEDIA_ROOT=/app/media ./scripts/backup_media.sh /var/backups/ev
```

If media lives in object storage, prefer native bucket **versioning +
cross-region replication** over tar archives.

## Restore

```bash
# 1. Take a fresh safety backup of the target first.
# 2. Restore the dump:
DATABASE_URL=postgres://user:pass@host:5432/db ./scripts/restore_db.sh db_20240101T020000Z.sql.gz
# 3. Apply any newer migrations:
python manage.py migrate --noinput
```

The restore script prompts for confirmation and stops on the first error.
**Test restores regularly** — an untested backup is not a backup.

## Recovery notes

- The database is the source of truth for users, stations, bookings, reviews.
- Media (station images) are non-critical and regenerable by re-upload; still
back them up to avoid broken links.
- The cache (Redis) is disposable — it rebuilds on demand. No backup needed.

## Monitoring recommendations

- **Uptime/health**: poll `/ready/` (DB + cache) and alert on non-200; `/live/`
for liveness.
- **Metrics**: request rate, p50/p95 latency, 4xx/5xx rates, DB connection pool
usage, Gunicorn worker saturation, Redis hit rate.
- **Logs**: ship stdout to a central store (Loki/CloudWatch/ELK). Watch the
`accounts.auth` stream for `account_locked` / repeated `login_failure`
(brute-force signal) and `ev_backend.graphql` for masked internal errors.
- **Alerts**: 5xx spike, `/ready/` failing, DB unreachable, backup job failure,
TLS-cert expiry, disk usage on the media volume.
- **Security**: alert on bursts of `pin_reset_requested` (OTP abuse) and on
rate-limit lockouts trending up.
Loading
Loading