Tau.Acuvim/portal/docker-compose.prod.yml
Diseri Pearson e17921a122 Add portal: customer-facing white-labeled monitoring stack
New top-level portal/ project, peer to console/ and firmware/. Delivers a
.NET 10 + React 18 + TimescaleDB + Grafana stack, one container set per
customer behind Traefik. Built in 12 phases per FrontEndPrompt spec; no
changes to existing console or firmware.

Backend (src/Tau.Acuvim.Portal/):
- .NET 10 minimal API, Serilog, ASP.NET Identity (cookie auth, lockout).
- Single AppDbContext with identity / app / monitoring schemas.
- MigrateAsync + TimescaleBootstrapper (idempotent hypertable creation)
  + IdentityBootstrapper (seeded admin + branding) on startup.
- Pure CostCalculator + DB-backed RateService for tariffs (effective-dated,
  TOU periods, VAT, fixed charges, per-municipality timezone).
- BrandingService with logo upload to mounted volume.
- Time-series ingest + bucketed query services (time_bucket aggregates,
  ON CONFLICT for idempotent re-delivery).
- ConfigOverviewService with redaction-by-construction (passwords never in
  payload).
- DataProtection keys persisted to /data/keys volume for cookie survival
  across container restarts.

Frontend (frontend/):
- React 18 + TypeScript + Vite + Ant Design 5 + TanStack Query.
- BrandingProvider + ThemedRoot for live re-themed white-labelling.
- RequireAuth / RequireRole guards.
- Pages: Login, Dashboard, Dashboards (embedded Grafana), Sites (admin),
  Settings tabs (Branding / Rates / Users / Grafana / App config).

Infra:
- Dev (docker-compose.yml) and prod (docker-compose.prod.yml) compose
  files. Three services per customer; Traefik subdomain + same-origin
  /grafana path-prefix routing wired with labels.
- Grafana 11 with provisioned timescaledb datasource (uid pinned) and
  starter power-overview.json dashboard with device template variable.
- Compose project name documented as lowercase (Compose v2 requirement).

Tests (tests/Tau.Acuvim.Portal.Tests/):
- xUnit, 40 tests. Covers CostCalculator (period match, TZ, overlap,
  VAT, fixed), ConnectionStringResolver (all 4 precedence branches incl.
  Production refusal), TariffValidator, DayOfWeekFlag.
- All passing locally against .NET 10.

Docs:
- README.md (onboarding + 11 spec sections), OPERATIONS.md (per-customer
  provisioning, secret rotation, backup, troubleshooting), TESTING.md
  (manual integration scenarios, frontend test scaffolding recipe).

Production safety guards:
- Refuses to start if Authentication:DefaultAdminPassword is unchanged
  default in Production.
- Refuses to start if Database:AutoProvisionLocalTimescaleDb=true in
  Production.
- Prod Grafana ships with anonymous off and auth mode unset (three
  options documented in README Security) so iframe refuses to load
  until a deliberate prod auth choice is made.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:30:30 +02:00

112 lines
4.7 KiB
YAML

# Production stack: one per customer.
# Requires:
# - COMPOSE_PROJECT_NAME=abc0001 (LOWERCASE; Compose v2 rejects uppercase. Use the
# lowercase form of the customer ID — abc0001 for ABC0001.)
# - CUSTOMER_HOST=abc0001.portal.example.com (Traefik routes by this Host)
# - POSTGRES_PASSWORD, GRAFANA_ADMIN_PASSWORD set in .env
# - An external Docker network `traefik-public` created by your Traefik stack
#
# Run with:
# docker compose -f docker-compose.prod.yml --env-file .env up -d
services:
portal:
build: .
image: tau-acuvim-portal:latest
container_name: ${COMPOSE_PROJECT_NAME}_portal
restart: unless-stopped
environment:
- ASPNETCORE_ENVIRONMENT=Production
- Application__PublicUrl=https://${CUSTOMER_HOST}
- Database__ConnectionString=Host=timescaledb;Port=5432;Database=${POSTGRES_DB:-power_monitoring};Username=${POSTGRES_USER:-power_user};Password=${POSTGRES_PASSWORD}
- Database__AutoProvisionLocalTimescaleDb=false
- Authentication__DefaultAdminEmail=${Authentication__DefaultAdminEmail}
- Authentication__DefaultAdminPassword=${Authentication__DefaultAdminPassword}
- Grafana__BaseUrl=https://${CUSTOMER_HOST}${Grafana__EmbedPathPrefix:-/grafana}
- Grafana__InternalUrl=http://grafana:3000
- Grafana__EmbedPathPrefix=${Grafana__EmbedPathPrefix:-/grafana}
depends_on:
timescaledb:
condition: service_healthy
volumes:
- portal-keys:/data/keys
- portal-branding:/data/branding
networks:
- default
- traefik-public
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-portal.rule=Host(`${CUSTOMER_HOST}`)"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-portal.entrypoints=websecure"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-portal.tls=true"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-portal.tls.certresolver=le"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-portal.priority=10"
- "traefik.http.services.${COMPOSE_PROJECT_NAME}-portal.loadbalancer.server.port=8080"
timescaledb:
image: timescale/timescaledb:2.17.2-pg16
container_name: ${COMPOSE_PROJECT_NAME}_timescale
restart: unless-stopped
environment:
- POSTGRES_DB=${POSTGRES_DB:-power_monitoring}
- POSTGRES_USER=${POSTGRES_USER:-power_user}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- timescale-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-power_user} -d ${POSTGRES_DB:-power_monitoring}"]
interval: 10s
timeout: 5s
retries: 10
grafana:
image: grafana/grafana:11.4.0
container_name: ${COMPOSE_PROJECT_NAME}_grafana
restart: unless-stopped
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
- GF_SECURITY_ALLOW_EMBEDDING=true
- GF_SERVER_ROOT_URL=https://${CUSTOMER_HOST}${Grafana__EmbedPathPrefix:-/grafana}
- GF_SERVER_SERVE_FROM_SUB_PATH=true
# PROD AUTH IS NOT WIRED YET (Phase 9 risk).
# Anonymous is OFF so dashboards do NOT serve until you choose:
# (a) Traefik forwardAuth middleware → /api/auth/check
# (b) Grafana auth.proxy (GF_AUTH_PROXY_*) with X-WEBAUTH-USER from portal/Traefik
# (c) Service-account API key + portal-minted render tokens
# See README "Embedding Grafana — production auth options".
- GF_AUTH_ANONYMOUS_ENABLED=false
- GF_USERS_ALLOW_SIGN_UP=false
- POSTGRES_DB=${POSTGRES_DB:-power_monitoring}
- POSTGRES_USER=${POSTGRES_USER:-power_user}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- grafana-data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
depends_on:
timescaledb:
condition: service_healthy
networks:
- default
- traefik-public
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.rule=Host(`${CUSTOMER_HOST}`) && PathPrefix(`${Grafana__EmbedPathPrefix:-/grafana}`)"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.entrypoints=websecure"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.tls=true"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.tls.certresolver=le"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.priority=20"
- "traefik.http.services.${COMPOSE_PROJECT_NAME}-grafana.loadbalancer.server.port=3000"
volumes:
portal-keys:
portal-branding:
timescale-data:
grafana-data:
networks:
traefik-public:
external: true