From ded52d1eced43b609095c7844e4bac0be3694d0b Mon Sep 17 00:00:00 2001 From: Alberto-Audrix Date: Thu, 10 Sep 2026 15:44:34 +0700 Subject: [PATCH] production docker deployment update --- DOCKER.md | 210 ++++++++++++++++++++++++++ README.md | 12 ++ docker-compose.yml | 169 +++++++++++++++++++++ docker/.env.example | 47 ++++++ docker/Dockerfile.api | 32 ++++ docker/Dockerfile.web | 25 +++ docker/compose.override.local.example | 10 ++ docker/crontab | 10 ++ docker/entrypoint-api.sh | 7 + docker/entrypoint-cron.sh | 31 ++++ docker/karung_sync_loop.sh | 9 ++ docker/nginx.conf | 54 +++++++ 12 files changed, 616 insertions(+) create mode 100644 DOCKER.md create mode 100644 docker-compose.yml create mode 100644 docker/.env.example create mode 100644 docker/Dockerfile.api create mode 100644 docker/Dockerfile.web create mode 100644 docker/compose.override.local.example create mode 100644 docker/crontab create mode 100644 docker/entrypoint-api.sh create mode 100644 docker/entrypoint-cron.sh create mode 100644 docker/karung_sync_loop.sh create mode 100644 docker/nginx.conf diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..a68b7bb --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,210 @@ +# Docker Deployment Guide + +Deploy **dashboard-cpsp-executive** (HQ) with Docker Compose. Same layout as site `dashboard-cpsp` (Postgres + API + Nginx frontend + cron), with HQ ports and city-edge syncs **off** by default. + +## Architecture + +``` +┌──────────────────────────────────────────────────────────────┐ +│ HQ Host Server │ +│ │ +│ ┌─────────────┐ /api/* ┌──────────────┐ │ +│ │ frontend │────────────►│ api │ │ +│ │ (Nginx) │ │ (Django) │ │ +│ │ port 80 │ │ port 8000 │ │ +│ └─────────────┘ └──────┬───────┘ │ +│ │ │ +│ ┌─────────────┐ ▼ │ +│ │ cron │────────────► ┌──────────────┐ │ +│ │ (same image)│ │ database │ │ +│ └─────────────┘ │ (PostgreSQL) │ │ +│ │ port 15433* │ │ +│ └──────────────┘ │ +└──────────────────────────────────────────────────────────────┘ +* 15433 on host → 5432 in container (SSH tunnel / DBeaver) + Host API debug port: 18001 → 8000 (site app uses 18000 / 15432) +``` + +| Container | Image / build | Host port | Role | +| ---------- | ------------------ | ---------------- | ----------------------------- | +| frontend | `docker/Dockerfile.web` | 80 → 80 | React SPA + Nginx API proxy | +| api | `docker/Dockerfile.api` | 18001 → 8000 | Gunicorn, migrations, static | +| cron | same as api | — | KPI rollups (+ optional edge syncs) | +| database | postgres:16-alpine | 15433 → 5432 | PostgreSQL | + +## Prerequisites + +- Docker 20.10+ +- Docker Compose v2+ +- Ports available: **80** (UI), **18001** (direct API), **15433** (Postgres on localhost) + +```bash +docker --version +docker compose version +``` + +## Quick start + +```bash +cd dashboard-cpsp-executive +cp docker/.env.example .env +# Edit .env — set SECRET_KEY and DB_PASSWORD + +docker compose up -d --build +``` + +API entrypoint runs `migrate` + `bootstrap_admin` on every start (needs `BOOTSTRAP_ADMIN_PASSWORD` in `.env`). + +**Production** (empty database, no demo data): log in with the bootstrap admin, then create users/sites via the UI. Do **not** run `seed_demo` on production. + +**Dev / demo** (sample data): + +```bash +docker compose exec api python manage.py seed_demo +``` + +### Verify + +```bash +docker compose ps +docker compose logs -f + +curl http://localhost/health +curl http://localhost/api/v1/health/ +``` + +Open **http://localhost** in a browser. + +## Configuration files (`docker/`) + +| File | Purpose | +| ---- | ------- | +| `Dockerfile.web` | Frontend image (Vite + Nginx) | +| `Dockerfile.api` | Backend image (Django + Gunicorn) | +| `nginx.conf` | Nginx proxy config for frontend | +| `entrypoint-api.sh` | API container startup | +| `entrypoint-cron.sh` | Cron container startup | +| `crontab` | Fallback schedule; runtime regenerated from `DASHBOARD_PUBLISH_*` | +| `.env.example` | Compose env template → copy to project root `.env` | +| `compose.override.local.example` | Optional local Postgres port override | + +## Configuration + +Compose reads variables from a root `.env` file. Template: `docker/.env.example`. + +Important production values: + +| Variable | Purpose | +| -------- | ------- | +| `SECRET_KEY` | Django secret — use a long random string | +| `DB_PASSWORD` | PostgreSQL password | +| `CSRF_TRUSTED_ORIGINS` | Must include your public UI origin (e.g. `https://executive.example.com`) | +| `CORS_ALLOWED_ORIGINS` | Same as above if the SPA is on a different origin | +| `BOOTSTRAP_ADMIN_USER` | Superuser username for `bootstrap_admin` (default `admin`) | +| `BOOTSTRAP_ADMIN_PASSWORD` | Superuser password (required) | +| `BOOTSTRAP_STAFF_USER` | Optional GM username; leave empty to skip | +| `BOOTSTRAP_STAFF_PASSWORD` | GM password (required when `BOOTSTRAP_STAFF_USER` is set) | +| `BOOTSTRAP_API_KEY` | Optional fixed API key for city inbound / scripts (hashed at rest) | + +City-edge sync flags default to **false** at HQ. Enable only if this host also pulls directly from IoT / karung / chicken-counting edge APIs. + +## Management + +```bash +# Start / stop +docker compose start +docker compose stop +docker compose restart + +# Logs +docker compose logs -f api +docker compose logs -f frontend +docker compose logs -f cron + +# Django shell +docker compose exec api python manage.py shell + +# Database (psql) +docker compose exec database psql -U executive -d executive + +# From host (port 15433) +psql -h localhost -p 15433 -U executive -d executive +``` + +### Update after code changes + +```bash +git pull +docker compose down +docker compose up -d --build +``` + +Rolling update (less downtime): + +```bash +docker compose build +docker compose up -d --no-deps --build api +docker compose up -d --no-deps --build cron +docker compose up -d --no-deps --build frontend +``` + +### Local DBeaver on Mac + +```bash +cp docker/compose.override.local.example docker-compose.override.yml +``` + +Adds `127.0.0.1:5432:5432` while keeping the server’s `15433` mapping. + +## Cron jobs + +The `cron` service runs: + +- **Every 10 min** — `sync_iot_from_api` / `sync_chicken_counting_from_edge` (no-op while sync flags are false) +- **At `DASHBOARD_PUBLISH_HOUR`:`DASHBOARD_PUBLISH_MINUTE` daily** — optional karung sync then `recompute_kpi_rollups` + +Karung 30s loop starts only when `KARUNG_WEB_ADMIN_SYNC_ENABLED=true`. + +Logs: `docker compose exec cron tail -f /var/log/cron.log` + +## Troubleshooting + +**API unhealthy** + +```bash +docker compose logs api +docker compose exec api python manage.py migrate --plan +``` + +**Frontend 502 on /api** + +```bash +docker compose ps +curl http://127.0.0.1:18001/api/v1/health/ +``` + +**Database connection errors** + +Check Postgres is healthy and credentials in `.env` match `docker-compose.yml` defaults. + +**Port conflicts** + +Change mappings in `docker-compose.yml`, e.g. `"8080:80"` for frontend. Site app already uses 80 / 18000 / 15432 on city hosts. + +**Reset database** (destructive) + +```bash +docker compose down -v +docker compose up -d --build +``` + +## Security notes + +- Do not commit `.env` with real secrets. +- Use strong `SECRET_KEY` and `DB_PASSWORD` in production. +- Restrict database port `15433` to localhost (already bound to `127.0.0.1`). +- Put HTTPS in front of port 80 (reverse proxy + Let's Encrypt) for public deployment. + +--- + +Docker deployment guide — dashboard-cpsp-executive diff --git a/README.md b/README.md index 698eb02..751f6d0 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,18 @@ City A/B/C (API+DB) --API key sync--> HQ mirror API+DB --session--> Executive FE | Site `dashboard-cpsp` | `:3001` | `:8000` | | HQ `dashboard-cpsp-executive` | `:3002` | `:8001` | +## Production (Docker) + +Same Compose layout as the site app. On the HQ host: UI **:80**, API debug **:18001**, Postgres **:15433**. + +```bash +cp docker/.env.example .env +# set SECRET_KEY, DB_PASSWORD, BOOTSTRAP_ADMIN_PASSWORD +docker compose up -d --build +``` + +See [DOCKER.md](DOCKER.md) for architecture, env vars, cron, and ops commands. + ## Setup **Backend** diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..013f622 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,169 @@ +name: dashboard-cpsp-executive + +services: + database: + image: postgres:16-alpine + container_name: dashboard-cpsp-executive-database + restart: unless-stopped + environment: + POSTGRES_USER: ${DB_USER:-executive} + POSTGRES_PASSWORD: ${DB_PASSWORD:-change_this_to_a_strong_password} + POSTGRES_DB: ${DB_NAME:-executive} + PGDATA: /var/lib/postgresql/data/pgdata + volumes: + - db-data:/var/lib/postgresql/data + networks: + - dashboard-cpsp-executive-network + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${DB_USER:-executive} -d ${DB_NAME:-executive}", + ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + # Host 15433 avoids clash with site dashboard-cpsp (15432). + # Mac dev: add docker-compose.override.yml from docker/compose.override.local.example for :5432. + ports: + - "127.0.0.1:15433:5432" + + api: + image: dashboard-cpsp-executive-api + build: + context: . + dockerfile: docker/Dockerfile.api + container_name: dashboard-cpsp-executive-api + restart: unless-stopped + ports: + - "127.0.0.1:18001:8000" + environment: + DEBUG: ${DEBUG:-false} + SECRET_KEY: ${SECRET_KEY:-test-secret-key} + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,api,frontend,executive.local} + CSRF_TRUSTED_ORIGINS: ${CSRF_TRUSTED_ORIGINS:-http://localhost,http://127.0.0.1,http://executive.local} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1,http://executive.local} + CORS_ALLOW_CREDENTIALS: "true" + DB_ENGINE: django.db.backends.postgresql + DB_NAME: ${DB_NAME:-executive} + DB_USER: ${DB_USER:-executive} + DB_PASSWORD: ${DB_PASSWORD:-change_this_to_a_strong_password} + DB_HOST: database + DB_PORT: "5432" + KARUNG_WEB_ADMIN_BASE_URL: ${KARUNG_WEB_ADMIN_BASE_URL:-http://host.docker.internal:5000} + KARUNG_WEB_ADMIN_TIMEOUT_SECONDS: ${KARUNG_WEB_ADMIN_TIMEOUT_SECONDS:-30} + KARUNG_WEB_ADMIN_SYNC_ENABLED: ${KARUNG_WEB_ADMIN_SYNC_ENABLED:-false} + DASHBOARD_PUBLISH_HOUR: ${DASHBOARD_PUBLISH_HOUR:-17} + DASHBOARD_PUBLISH_MINUTE: ${DASHBOARD_PUBLISH_MINUTE:-0} + IOT_SYNC_ENABLED: ${IOT_SYNC_ENABLED:-false} + IOT_API_BASE_URL: ${IOT_API_BASE_URL:-} + IOT_FLOCK_ID_MAP: ${IOT_FLOCK_ID_MAP:-{}} + IOT_SYNC_LOOKBACK_MINUTES: ${IOT_SYNC_LOOKBACK_MINUTES:-20} + IOT_SYNC_MAX_PAGES: ${IOT_SYNC_MAX_PAGES:-10} + CHICKEN_COUNTING_EDGE_BASE_URL: ${CHICKEN_COUNTING_EDGE_BASE_URL:-} + CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS: ${CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS:-30} + CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED: ${CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED:-false} + CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED: ${CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED:-false} + CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED: ${CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED:-false} + BOOTSTRAP_API_KEY: ${BOOTSTRAP_API_KEY:-} + BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin} + BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-Pr04dm1n} + BOOTSTRAP_STAFF_USER: ${BOOTSTRAP_STAFF_USER:-} + BOOTSTRAP_STAFF_PASSWORD: ${BOOTSTRAP_STAFF_PASSWORD:-} + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - api-media:/app/media + depends_on: + database: + condition: service_healthy + networks: + - dashboard-cpsp-executive-network + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; r=urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health/'); exit(0 if r.status == 200 else 1)", + ] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s + + cron: + image: dashboard-cpsp-executive-cron + build: + context: . + dockerfile: docker/Dockerfile.api + container_name: dashboard-cpsp-executive-cron + restart: unless-stopped + entrypoint: ["/app/entrypoint-cron.sh"] + environment: + DEBUG: ${DEBUG:-false} + SECRET_KEY: ${SECRET_KEY:-test-secret-key} + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,api,frontend,executive.local} + DB_ENGINE: django.db.backends.postgresql + DB_NAME: ${DB_NAME:-executive} + DB_USER: ${DB_USER:-executive} + DB_PASSWORD: ${DB_PASSWORD:-change_this_to_a_strong_password} + DB_HOST: database + DB_PORT: "5432" + KARUNG_WEB_ADMIN_BASE_URL: ${KARUNG_WEB_ADMIN_BASE_URL:-http://host.docker.internal:5000} + KARUNG_WEB_ADMIN_TIMEOUT_SECONDS: ${KARUNG_WEB_ADMIN_TIMEOUT_SECONDS:-30} + KARUNG_WEB_ADMIN_SYNC_ENABLED: ${KARUNG_WEB_ADMIN_SYNC_ENABLED:-false} + DASHBOARD_PUBLISH_HOUR: ${DASHBOARD_PUBLISH_HOUR:-17} + DASHBOARD_PUBLISH_MINUTE: ${DASHBOARD_PUBLISH_MINUTE:-0} + IOT_SYNC_ENABLED: ${IOT_SYNC_ENABLED:-false} + IOT_API_BASE_URL: ${IOT_API_BASE_URL:-} + IOT_FLOCK_ID_MAP: ${IOT_FLOCK_ID_MAP:-{}} + IOT_SYNC_LOOKBACK_MINUTES: ${IOT_SYNC_LOOKBACK_MINUTES:-20} + IOT_SYNC_MAX_PAGES: ${IOT_SYNC_MAX_PAGES:-10} + CHICKEN_COUNTING_EDGE_BASE_URL: ${CHICKEN_COUNTING_EDGE_BASE_URL:-} + CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS: ${CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS:-30} + CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED: ${CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED:-false} + CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED: ${CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED:-false} + CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED: ${CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED:-false} + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - api-media:/app/media + depends_on: + api: + condition: service_healthy + networks: + - dashboard-cpsp-executive-network + + frontend: + image: dashboard-cpsp-executive-frontend + build: + context: . + dockerfile: docker/Dockerfile.web + container_name: dashboard-cpsp-executive-frontend + restart: unless-stopped + ports: + - "80:80" + depends_on: + api: + condition: service_healthy + networks: + - dashboard-cpsp-executive-network + healthcheck: + test: + ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + +networks: + dashboard-cpsp-executive-network: + driver: bridge + +volumes: + db-data: + driver: local + api-media: + driver: local diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000..942d8d6 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,47 @@ +# Docker Compose environment — copy to project root: +# cp docker/.env.example .env + +# --- PostgreSQL --- +DB_USER=executive +DB_PASSWORD=change_this_to_a_strong_password +DB_NAME=executive + +# --- Django --- +SECRET_KEY=change-me-in-production-use-a-long-random-string +DEBUG=false +ALLOWED_HOSTS=localhost,127.0.0.1,api,frontend,executive.local +CSRF_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1,http://executive.local +CORS_ALLOWED_ORIGINS=http://localhost,http://127.0.0.1,http://executive.local + +# Production admin (bootstrap_admin — no demo data) +BOOTSTRAP_ADMIN_USER=admin +BOOTSTRAP_ADMIN_PASSWORD=Pr04dm1n + +# Optional production GM (leave BOOTSTRAP_STAFF_USER empty to skip) +BOOTSTRAP_STAFF_USER= +BOOTSTRAP_STAFF_PASSWORD= + +# Optional: fixed API key for city-site inbound / scripts (hashed at rest) +BOOTSTRAP_API_KEY= + +# City-edge syncs OFF at HQ — data should arrive via city-site mirror sync. +# Enable only if this box also pulls directly from edge services. +KARUNG_WEB_ADMIN_SYNC_ENABLED=false +IOT_SYNC_ENABLED=false +CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED=false +CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED=false +CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED=false + +# Dashboard publish cutoff (WIB): day D visible from 17:00 on day D +DASHBOARD_PUBLISH_HOUR=17 +DASHBOARD_PUBLISH_MINUTE=0 + +# Optional edge endpoints (unused while sync flags are false) +KARUNG_WEB_ADMIN_BASE_URL=http://host.docker.internal:5000 +KARUNG_WEB_ADMIN_TIMEOUT_SECONDS=30 +IOT_API_BASE_URL= +IOT_SYNC_LOOKBACK_MINUTES=20 +IOT_SYNC_MAX_PAGES=10 +IOT_FLOCK_ID_MAP={} +CHICKEN_COUNTING_EDGE_BASE_URL= +CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS=30 diff --git a/docker/Dockerfile.api b/docker/Dockerfile.api new file mode 100644 index 0000000..c938d75 --- /dev/null +++ b/docker/Dockerfile.api @@ -0,0 +1,32 @@ +# Backend — Django + Gunicorn (+ cron in sibling container) +FROM python:3.12-slim-bookworm + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libjpeg62-turbo-dev \ + zlib1g-dev \ + libpq-dev \ + cron \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY backend/requirements.txt . +RUN pip install -r requirements.txt + +COPY backend/ . +COPY docker/entrypoint-api.sh docker/entrypoint-cron.sh docker/karung_sync_loop.sh /app/ +COPY docker/crontab /etc/cron.d/executive + +RUN chmod +x /app/entrypoint-api.sh /app/entrypoint-cron.sh /app/karung_sync_loop.sh \ + && mkdir -p /app/data /app/media /app/staticfiles \ + && chmod 0644 /etc/cron.d/executive \ + && crontab /etc/cron.d/executive + +EXPOSE 8000 + +ENTRYPOINT ["/app/entrypoint-api.sh"] diff --git a/docker/Dockerfile.web b/docker/Dockerfile.web new file mode 100644 index 0000000..3e98fe0 --- /dev/null +++ b/docker/Dockerfile.web @@ -0,0 +1,25 @@ +# Frontend — multi-stage build (Vite + Nginx) +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . + +ENV VITE_API_BASE=/api/v1 + +RUN npm run build + +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://localhost:80/health || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker/compose.override.local.example b/docker/compose.override.local.example new file mode 100644 index 0000000..dd0faf5 --- /dev/null +++ b/docker/compose.override.local.example @@ -0,0 +1,10 @@ +# On your Mac only — copy to docker-compose.override.yml (gitignored, not pushed): +# cp docker/compose.override.local.example docker-compose.override.yml +# +# Compose merges with docker-compose.yml: you keep the server’s 15433 mapping and add 5432 +# for local DBeaver, restore scripts, or host tools. If 5432 is busy, use 5433:5432. + +services: + database: + ports: + - "127.0.0.1:5432:5432" diff --git a/docker/crontab b/docker/crontab new file mode 100644 index 0000000..ba8ff1c --- /dev/null +++ b/docker/crontab @@ -0,0 +1,10 @@ +SHELL=/bin/sh +PATH=/usr/local/bin:/usr/bin:/bin + +# NOTE: docker/entrypoint-cron.sh regenerates this file from DASHBOARD_PUBLISH_HOUR/MINUTE +# at container start. Edge syncs no-op when *_SYNC_ENABLED=false (HQ default). + +*/10 * * * * root cd /app && /usr/local/bin/python manage.py sync_iot_from_api >> /var/log/cron.log 2>&1 +*/10 * * * * root cd /app && /usr/local/bin/python manage.py sync_chicken_counting_from_edge >> /var/log/cron.log 2>&1 + +0 17 * * * root cd /app && /usr/local/bin/python manage.py sync_karung_from_web_admin >> /var/log/cron.log 2>&1 && /usr/local/bin/python manage.py recompute_kpi_rollups >> /var/log/cron.log 2>&1 diff --git a/docker/entrypoint-api.sh b/docker/entrypoint-api.sh new file mode 100644 index 0000000..d176fdd --- /dev/null +++ b/docker/entrypoint-api.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -e +cd /app +python manage.py migrate --noinput +python manage.py bootstrap_admin +python manage.py collectstatic --noinput +exec gunicorn config.wsgi:application -c config/gunicorn.py diff --git a/docker/entrypoint-cron.sh b/docker/entrypoint-cron.sh new file mode 100644 index 0000000..6abc8e8 --- /dev/null +++ b/docker/entrypoint-cron.sh @@ -0,0 +1,31 @@ +#!/bin/sh +set -e +cd /app +python manage.py migrate --noinput +printenv | grep -E '^(PATH|DJANGO_|SECRET_|DB_|KARUNG_|IOT_|CHICKEN_COUNTING_|DASHBOARD_|DEBUG|ALLOWED_|BOOTSTRAP_|API_|CORS_|CSRF_|SESSION_)' \ + > /etc/environment || true + +# Render publish-time cron from DASHBOARD_PUBLISH_* (default 17:00). +# Edge syncs are OFF by default at HQ (mirror via city-sites); commands no-op when disabled. +PUBLISH_HOUR="${DASHBOARD_PUBLISH_HOUR:-17}" +PUBLISH_MINUTE="${DASHBOARD_PUBLISH_MINUTE:-0}" +CRON_FILE=/etc/cron.d/executive +{ + echo "SHELL=/bin/sh" + echo "PATH=/usr/local/bin:/usr/bin:/bin" + echo "" + echo "# Optional edge syncs (skipped when *_SYNC_ENABLED=false)." + echo "*/10 * * * * root cd /app && /usr/local/bin/python manage.py sync_iot_from_api >> /var/log/cron.log 2>&1" + echo "*/10 * * * * root cd /app && /usr/local/bin/python manage.py sync_chicken_counting_from_edge >> /var/log/cron.log 2>&1" + echo "" + echo "# Daily publish — optional karung sync then KPI rollups at configured cutoff." + echo "${PUBLISH_MINUTE} ${PUBLISH_HOUR} * * * root cd /app && /usr/local/bin/python manage.py sync_karung_from_web_admin >> /var/log/cron.log 2>&1 && /usr/local/bin/python manage.py recompute_kpi_rollups >> /var/log/cron.log 2>&1" +} > "$CRON_FILE" +chmod 0644 "$CRON_FILE" +crontab "$CRON_FILE" + +if [ "${KARUNG_WEB_ADMIN_SYNC_ENABLED:-false}" = "true" ] || [ "${KARUNG_WEB_ADMIN_SYNC_ENABLED:-false}" = "True" ]; then + /app/karung_sync_loop.sh & +fi + +exec cron -f diff --git a/docker/karung_sync_loop.sh b/docker/karung_sync_loop.sh new file mode 100644 index 0000000..a4e78cc --- /dev/null +++ b/docker/karung_sync_loop.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Sub-minute karung sync — cron cannot schedule under 1 minute. +# Only started when KARUNG_WEB_ADMIN_SYNC_ENABLED=true (off by default at HQ). +set -eu +cd /app +while true; do + /usr/local/bin/python manage.py sync_karung_from_web_admin >> /var/log/cron.log 2>&1 || true + sleep 30 +done diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..e819b1c --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,54 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript; + + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location /api/ { + proxy_pass http://api:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 1800s; + proxy_connect_timeout 1800s; + proxy_send_timeout 1800s; + } + + location /media/ { + proxy_pass http://api:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } +}