diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5569045..16f5b1c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,6 +36,9 @@ repos: # Helm charts under infra/; current YAML files are all # single-document and unaffected. args: [--allow-multiple-documents] + # docker-compose.override.yml uses Compose's custom `!override` + # tag, which strict YAML parsers (PyYAML) cannot resolve. + exclude: ^infra/docker-compose\.override\.yml$ - id: check-json # tsconfig*.json files use JSONC (comments allowed), which is # valid for TypeScript but rejected by strict JSON parsers. diff --git a/README.md b/README.md index 07893de..1dae6ea 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ repo/ │ └── generated/ # ⚠ Generated — do not edit by hand ├── web-client/ # React SPA (Vite, TypeScript) │ └── src/api.ts # ⚠ Generated — do not edit by hand -├── infra/ # docker-compose, Traefik config, Helm/Terraform +├── infra/ # docker-compose, Traefik config, Terraform, Ansible └── .github/workflows/ # CI/CD pipelines ``` @@ -52,7 +52,7 @@ The Spring Boot services and the GenAI service share a **PostgreSQL** database. | GenAI Service | `/api/v1/helper/…` | 5000 | Python 3.12, Flask, LangChain | | Web Client | `/` | 8080 | React, Vite | | Swagger UI | `/docs` | 8080 | swaggerapi/swagger-ui | -| Traefik dashboard | `http://localhost:8080` | — | Traefik v3 | +| Traefik dashboard | `http://localhost:8080` (local only) | — | Traefik v3 | | PostgreSQL | internal only | 5432 | postgres:15 | ## Code Generation @@ -114,6 +114,98 @@ git push --no-verify The full hook configuration lives in [`.pre-commit-config.yaml`](.pre-commit-config.yaml) and the helper scripts under [`scripts/hooks/`](scripts/hooks/). +## Running Locally + +Spin up the full stack on your machine with Docker Compose: + +```bash +cd infra +docker compose up -d --build +``` + +This auto-merges [`infra/docker-compose.override.yml`](infra/docker-compose.override.yml), +which strips TLS / Let's Encrypt / Host-based routing from the base file so +everything is reachable on plain HTTP: + +| URL | Service | +|---|---| +| | Web client | +| | Swagger UI | +| | APIs (organization, members, events, feedback, finance, letters, helper) | +| | Traefik dashboard | + +> **Do not run** `docker compose -f infra/docker-compose.yml up` locally — that +> skips the override, causing Traefik to request a real Let's Encrypt cert for +> the production hostname from your laptop. Failed challenges count toward the +> production rate limit. + +Tear down: +```bash +cd infra && docker compose down # keeps the postgres volume +cd infra && docker compose down -v # wipes the postgres volume too +``` + +## Production Deployment + +The stack runs on a single Azure VM in **UAE North**, fronted by Traefik with a +real TLS certificate from Let's Encrypt (production CA). Everything is +automated; no manual VM access is required for normal deploys. + +**Live URL:** + +### Infrastructure stack + +| Layer | Tool | What it does | +|---|---|---| +| Provisioning | **Terraform** (AzureRM ~> 4.0) | Resource group, VNet, NSG (22/80/443), static public IP + free Azure FQDN, Ubuntu 24.04 VM | +| Configuration | **Ansible** | Installs Docker, clones repo, writes `.env`, runs `docker compose up` | +| CI/CD | **GitHub Actions** (OIDC, no client secrets) | `infra.yml` (manual: plan/apply/destroy) and `cd.yml` (auto on push to `main`) | +| Remote state | **Azure Blob Storage** (`stteamdevoopstfstate/tfstate`) | Shared, locked Terraform state — survives between CI runs | +| TLS | **Let's Encrypt** (HTTP-01 via Traefik) | Cert persisted in a Docker volume; auto-renewed | + +### GitHub Actions workflows + +- **`infra` workflow** — manual (`workflow_dispatch`). Choose `plan`, `apply`, or `destroy`. +- **`cd` workflow** — runs automatically on every push to `main` (and is also `workflow_dispatch`-able). Deploys the current `main` to the VM via Ansible. + +### Required GitHub secrets / variables + +| Kind | Name | Purpose | +|---|---|---| +| Variable | `AZURE_CLIENT_ID` | OIDC app registration (Service Principal) | +| Variable | `AZURE_TENANT_ID` | Azure AD tenant | +| Variable | `AZURE_SUBSCRIPTION_ID` | Target subscription | +| Secret | `VM_SSH_PUBLIC_KEY` | Public key planted on the VM by Terraform | +| Secret | `SSH_PRIVATE_KEY` | Matching private key for Ansible to SSH in | +| Secret | `VM_HOST` | Host Ansible connects to — use the FQDN above | +| Secret | `GENAI_ENV_CONTENT` | Contents of `services/py-genai-helper/.env` | + +The OIDC service principal needs `Contributor` on the subscription (to manage +resources in `rg-team-devoops`) and `Storage Blob Data Contributor` on the +state account `stteamdevoopstfstate` (to read/write tfstate). + +### Typical workflow + +1. **Change infra** → push to `main` (or any branch), trigger `infra` workflow with `apply`. +2. **Change app code** → merge to `main`; `cd` runs automatically and redeploys. +3. **Tear down** → trigger `infra` workflow with `destroy`. + +### Running Terraform locally + +```bash +az login +az account set --subscription +export ARM_SUBSCRIPTION_ID= +export ARM_USE_AZUREAD=true +cd infra/terraform +echo "admin_ssh_public_key = \"$(cat ~/.ssh/team-devoops-azure.pub)\"" > terraform.tfvars +terraform init +terraform plan +``` + +Local and CI share the same remote state, so do not run `apply` in both at the +same time (the backend's blob lease will block one, but coordinate anyway). + ## Docs - [Problem Statement](docs/problem-statement.md) diff --git a/infra/docker-compose.override.yml b/infra/docker-compose.override.yml new file mode 100644 index 0000000..06971f3 --- /dev/null +++ b/infra/docker-compose.override.yml @@ -0,0 +1,108 @@ +# Local development override. +# +# Compose auto-merges this file ONLY when running `docker compose` with no `-f` +# flags. The production Ansible playbook always passes `-f infra/docker-compose.yml` +# explicitly, so this override is never used on the VM. +# +# Usage: +# cd infra && docker compose up -d --build +# +# What this override does: +# * Removes TLS (no Let's Encrypt, no HTTPS, no 443) -- local has no public DNS +# * Strips the Host(...) requirement from every router so localhost works +# * Disables the HTTP -> HTTPS redirect so plain http://localhost works +# +# Access locally: +# http://localhost/ web client +# http://localhost/docs Swagger UI +# http://localhost/api/v1//... APIs +# http://localhost:8080 Traefik dashboard + +services: + traefik: + command: + - "--api.insecure=true" + - "--providers.docker=true" + - "--providers.docker.exposedByDefault=false" + - "--providers.docker.network=proxy" + - "--entrypoints.web.address=:80" + ports: !override + - "80:80" + - "8080:8080" + + py-genai-helper: + labels: !override + - "traefik.enable=true" + - "traefik.http.routers.py-genai-helper.entrypoints=web" + - "traefik.http.routers.py-genai-helper.rule=PathPrefix(`/api/v1/helper`)" + - "traefik.http.middlewares.helper-stripprefix.stripprefix.prefixes=/api/v1/helper" + - "traefik.http.routers.py-genai-helper.middlewares=helper-stripprefix" + - "traefik.http.services.py-genai-helper.loadbalancer.server.port=5000" + + organization-service: + labels: !override + - "traefik.enable=true" + - "traefik.http.routers.organization-service.entrypoints=web" + - "traefik.http.routers.organization-service.rule=PathPrefix(`/api/v1/organization`)" + - "traefik.http.middlewares.organization-stripprefix.stripprefix.prefixes=/api/v1/organization" + - "traefik.http.routers.organization-service.middlewares=organization-stripprefix" + - "traefik.http.services.organization-service.loadbalancer.server.port=8080" + + member-service: + labels: !override + - "traefik.enable=true" + - "traefik.http.routers.member-service.entrypoints=web" + - "traefik.http.routers.member-service.rule=PathPrefix(`/api/v1/members`)" + - "traefik.http.middlewares.member-stripprefix.stripprefix.prefixes=/api/v1/members" + - "traefik.http.routers.member-service.middlewares=member-stripprefix" + - "traefik.http.services.member-service.loadbalancer.server.port=8080" + + event-service: + labels: !override + - "traefik.enable=true" + - "traefik.http.routers.event-service.entrypoints=web" + - "traefik.http.routers.event-service.rule=PathPrefix(`/api/v1/events`)" + - "traefik.http.middlewares.event-stripprefix.stripprefix.prefixes=/api/v1/events" + - "traefik.http.routers.event-service.middlewares=event-stripprefix" + - "traefik.http.services.event-service.loadbalancer.server.port=8080" + + feedback-service: + labels: !override + - "traefik.enable=true" + - "traefik.http.routers.feedback-service.entrypoints=web" + - "traefik.http.routers.feedback-service.rule=PathPrefix(`/api/v1/feedback`)" + - "traefik.http.middlewares.feedback-stripprefix.stripprefix.prefixes=/api/v1/feedback" + - "traefik.http.routers.feedback-service.middlewares=feedback-stripprefix" + - "traefik.http.services.feedback-service.loadbalancer.server.port=8080" + + finance-service: + labels: !override + - "traefik.enable=true" + - "traefik.http.routers.finance-service.entrypoints=web" + - "traefik.http.routers.finance-service.rule=PathPrefix(`/api/v1/finance`)" + - "traefik.http.middlewares.finance-stripprefix.stripprefix.prefixes=/api/v1/finance" + - "traefik.http.routers.finance-service.middlewares=finance-stripprefix" + - "traefik.http.services.finance-service.loadbalancer.server.port=8080" + + letter-service: + labels: !override + - "traefik.enable=true" + - "traefik.http.routers.letter-service.entrypoints=web" + - "traefik.http.routers.letter-service.rule=PathPrefix(`/api/v1/letters`)" + - "traefik.http.middlewares.letter-stripprefix.stripprefix.prefixes=/api/v1/letters" + - "traefik.http.routers.letter-service.middlewares=letter-stripprefix" + - "traefik.http.services.letter-service.loadbalancer.server.port=8080" + + api-docs: + labels: !override + - "traefik.enable=true" + - "traefik.http.routers.api-docs.entrypoints=web" + - "traefik.http.routers.api-docs.rule=PathPrefix(`/docs`)" + - "traefik.http.services.api-docs.loadbalancer.server.port=8080" + + web-client: + labels: !override + - "traefik.enable=true" + - "traefik.http.routers.web-client.entrypoints=web" + - "traefik.http.routers.web-client.rule=PathPrefix(`/`)" + - "traefik.http.services.web-client.loadbalancer.server.port=8080" diff --git a/web-client/src/features/payments/client.ts b/web-client/src/features/payments/client.ts index 29e2d1a..6c286ce 100644 --- a/web-client/src/features/payments/client.ts +++ b/web-client/src/features/payments/client.ts @@ -1,5 +1,5 @@ import axios from 'axios' export const paymentsClient = axios.create({ - baseURL: '/api/v1/finances', + baseURL: '/api/v1/finance', })