diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 3e38554..0aa6842 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -8,6 +8,7 @@ on: permissions: contents: read + packages: write jobs: deploy: @@ -53,3 +54,100 @@ jobs: infra/ansible/playbook.yml \ -e "repo_url=https://github.com/${{ github.repository }}.git" \ -e "genai_env_file=/tmp/genai.env" + + # ------------------------------------------------------------------ + # Build & push all service images to GitHub Container Registry. + # Kubernetes pulls these (unlike the VM, which builds locally), so this + # runs before the Helm deploy. + # ------------------------------------------------------------------ + docker-push: + name: docker-push (ghcr) + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - service: organization-service + context: services/spring-organization + - service: member-service + context: services/spring-member + - service: event-service + context: services/spring-event + - service: feedback-service + context: services/spring-feedback + - service: finance-service + context: services/spring-finance + - service: letter-service + context: services/spring-letter + - service: py-genai-helper + context: services/py-genai-helper + - service: web-client + context: web-client + - service: api-docs + context: api + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute lowercase image name + id: img + run: echo "repo=ghcr.io/${GITHUB_REPOSITORY,,}/${{ matrix.service }}" >> "$GITHUB_OUTPUT" + + - name: Build & push + uses: docker/build-push-action@v6 + with: + context: ${{ matrix.context }} + push: true + tags: | + ${{ steps.img.outputs.repo }}:${{ github.sha }} + ${{ steps.img.outputs.repo }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + # ------------------------------------------------------------------ + # Deploy to the RKE2 Kubernetes cluster via Helm. + # ------------------------------------------------------------------ + deploy-k8s: + name: deploy (Kubernetes/Helm) + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: docker-push + env: + NAMESPACE: ge83mom-devops26 + steps: + - uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + + - name: Write kubeconfig + run: | + mkdir -p ~/.kube + printf '%s' "${{ secrets.KUBECONFIG }}" > ~/.kube/config + chmod 600 ~/.kube/config + + - name: Create/refresh genai-env secret + env: + GENAI_ENV_CONTENT: ${{ secrets.GENAI_ENV_CONTENT }} + run: | + printf '%s\n' "$GENAI_ENV_CONTENT" > /tmp/genai.env + kubectl -n "$NAMESPACE" create secret generic genai-env \ + --from-env-file=/tmp/genai.env \ + --dry-run=client -o yaml | kubectl apply -f - + + - name: Helm upgrade + run: | + helm upgrade --install team-devoops infra/helm/team-devoops \ + --namespace "$NAMESPACE" \ + --set global.image.tag=${{ github.sha }} \ + --wait --timeout 5m diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c74293..3273606 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -432,6 +432,38 @@ jobs: with: file_glob: 'api/openapi.yaml' + # ------------------------------------------------------------------ + # Helm chart lint + render + schema validation. + # ------------------------------------------------------------------ + helm-validate: + name: helm-validate + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + + - name: Lint chart + run: helm lint infra/helm/team-devoops + + - name: Render templates + run: | + helm template team-devoops infra/helm/team-devoops \ + --set global.image.tag=ci-validate > /tmp/rendered.yaml + test -s /tmp/rendered.yaml + + - name: Install kubeconform + run: | + curl -sSLo kubeconform.tar.gz \ + https://github.com/yannh/kubeconform/releases/latest/download/kubeconform-linux-amd64.tar.gz + tar xf kubeconform.tar.gz + sudo mv kubeconform /usr/local/bin/ + + - name: Validate rendered manifests + run: kubeconform -strict -summary /tmp/rendered.yaml + # ------------------------------------------------------------------ # Aggregate gate - use this single check for branch protection. # ------------------------------------------------------------------ @@ -451,6 +483,7 @@ jobs: - docker-build - codeql - openapi-lint + - helm-validate steps: - name: Verify all required jobs succeeded run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5569045..2b76bfa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,6 +36,15 @@ 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. + # infra/helm/**/templates/* are Helm (Go-template) files, not plain + # YAML, so PyYAML cannot parse their `{{ ... }}` directives. + exclude: | + (?x)^( + infra/docker-compose\.override\.yml| + infra/helm/.*/templates/.* + )$ - 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..67c199a 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, Helm └── .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,122 @@ 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 **and** to the Kubernetes cluster via Helm (see [Kubernetes deployment](#kubernetes-deployment-helm)). + +### 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` | +| Secret | `KUBECONFIG` | Kubeconfig for the RKE2 cluster (used by the `deploy-k8s` job) | + +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). + +### Kubernetes deployment (Helm) + +In addition to the Azure VM, the stack is also deployed to a **Kubernetes +cluster** (TUM RKE2) via a Helm umbrella chart. Both deploys run in parallel on +push to `main`; the VM path is unchanged. + +| Aspect | Value | +|---|---| +| Chart | [`infra/helm/team-devoops`](infra/helm/team-devoops) | +| Namespace | `ge83mom-devops26` | +| Host | | +| Ingress | cluster `nginx` ingress (path-prefix routing, prefix stripped per service) | +| Images | built and pushed to `ghcr.io/aet-devops26/team-devoops/` | +| Database | in-cluster PostgreSQL `StatefulSet` + PVC (cluster default StorageClass) | + +The `cd` workflow's `docker-push` job builds and pushes all service images to +ghcr (tagged with the commit SHA), then `deploy-k8s` runs `helm upgrade +--install` against the cluster. On pull requests, the `ci` workflow's +`helm-validate` job lints and schema-validates the chart with `kubeconform`. + +See [`infra/helm/README.md`](infra/helm/README.md) for the chart layout, required +one-time secrets (`genai-env`, `ghcr-pull`), and manual deploy instructions. + ## Docs - [Problem Statement](docs/problem-statement.md) diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..f59e4fe --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,9 @@ +# Bakes the OpenAPI spec into the Swagger UI image so it can be pulled like any +# other service (no runtime volume/ConfigMap needed). +FROM swaggerapi/swagger-ui:latest + +# Served by swagger-ui from this path; SWAGGER_JSON points at it. +COPY openapi.yaml /app/openapi.yaml + +ENV SWAGGER_JSON=/app/openapi.yaml +ENV BASE_URL=/docs diff --git a/infra/.gitkeep b/infra/.gitkeep deleted file mode 100644 index e69de29..0000000 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/infra/helm/README.md b/infra/helm/README.md new file mode 100644 index 0000000..afbbfc8 --- /dev/null +++ b/infra/helm/README.md @@ -0,0 +1,123 @@ +# team-devoops Helm chart + +Umbrella chart that deploys the whole team-devoops platform (6 Spring services, +`py-genai-helper`, `web-client`, `api-docs`, and an in-cluster Postgres) to the +TUM RKE2 cluster. This is the Kubernetes deployment path. + +## Layout + +``` +infra/helm/team-devoops/ + Chart.yaml + values.yaml # global image/ingress/db config + service catalogue + templates/ + _helpers.tpl # naming/label/image helpers + deployment.yaml # generic Deployment rendered per service + service.yaml # generic ClusterIP Service rendered per service + ingress.yaml # nginx ingress (prefix-strip + plain rules) + configmap-db.yaml # SPRING_DATASOURCE_URL/USERNAME + secret-db.yaml # SPRING_DATASOURCE_PASSWORD / POSTGRES_PASSWORD + postgres-statefulset.yaml # Postgres + PVC (cluster default StorageClass) + postgres-service.yaml +``` + +The `api-docs` (Swagger UI) image is built from [api/Dockerfile](../../api/Dockerfile), +which bakes `api/openapi.yaml` into the image — no runtime ConfigMap/volume needed. + +## Target environment + +| Setting | Value | +| -------------- | -------------------------------------------------- | +| Namespace | `ge83mom-devops26` | +| Host | `ge83mom-devops26.stud.k8s.aet.cit.tum.de` | +| Ingress class | `nginx` (override `ingress.className` if different)| +| StorageClass | cluster default `csi-rbd-sc` (leave empty in values)| +| Image registry | `ghcr.io/aet-devops26/team-devoops/` | + +## One-time setup + +### 1. py-genai-helper environment Secret + +The chart references (but does not create) a Secret named `genai-env` for the +GenAI helper. The `deploy-k8s` pipeline job creates/refreshes it automatically +from the `GENAI_ENV_CONTENT` GitHub secret. For a manual deploy, create it from +the same `.env` used by docker compose: + +```bash +kubectl -n ge83mom-devops26 create secret generic genai-env \ + --from-env-file=services/py-genai-helper/.env +``` + +### 2. ghcr image pull secret + +The service images are pushed to ghcr as private packages, so the chart is +preconfigured to pull them via an `imagePullSecrets` entry named `ghcr-pull` +(see `global.imagePullSecrets` in `values.yaml`). Create that secret once in the +namespace with a GitHub PAT that has the `read:packages` scope: + +```bash +kubectl -n ge83mom-devops26 create secret docker-registry ghcr-pull \ + --docker-server=ghcr.io \ + --docker-username= \ + --docker-password= +``` + +If you instead make the org packages public, remove (or empty) +`global.imagePullSecrets` in `values.yaml`. + +## Manual deploy + +```bash +# 1. Point kubectl/helm at the cluster +export KUBECONFIG=~/.kube/config # the Rancher token kubeconfig + +# 2. Deploy / upgrade +helm upgrade --install team-devoops infra/helm/team-devoops \ + --namespace ge83mom-devops26 \ + --set global.image.tag= \ + --wait --timeout 5m +``` + +## Validate + +```bash +helm lint infra/helm/team-devoops +helm template team-devoops infra/helm/team-devoops | less + +kubectl -n ge83mom-devops26 get pods +kubectl -n ge83mom-devops26 get ingress -o wide # ADDRESS should populate + +curl https://ge83mom-devops26.stud.k8s.aet.cit.tum.de/api/v1/members +# open https://ge83mom-devops26.stud.k8s.aet.cit.tum.de/docs and / +``` + +## Pipeline integration + +On pull requests, `.github/workflows/ci.yml` runs a `helm-validate` job that +lints the chart, renders the templates, and validates the rendered manifests +against the Kubernetes schemas with `kubeconform`. + +`.github/workflows/cd.yml` runs on push to `main`: + +- **deploy** — existing Azure VM deploy via Ansible (unchanged). +- **docker-push** then **deploy-k8s** — builds & pushes all images to ghcr, then + runs the `helm upgrade` above with `global.image.tag=`. + +Required GitHub secrets: `KUBECONFIG` (the Rancher kubeconfig), `GENAI_ENV_CONTENT` +(reused from the VM deploy). ghcr auth uses the built-in `GITHUB_TOKEN`. + +## Adding a service + +Add an entry under `services:` in `values.yaml`: + +```yaml +services: + my-service: + path: /api/v1/mine + port: 8080 + db: true # inject SPRING_DATASOURCE_* (ConfigMap + Secret) + health: /actuator/health + stripPrefix: true # strip the path prefix before forwarding +``` + +No template changes are needed. diff --git a/infra/helm/team-devoops/Chart.yaml b/infra/helm/team-devoops/Chart.yaml new file mode 100644 index 0000000..3314407 --- /dev/null +++ b/infra/helm/team-devoops/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: team-devoops +description: Umbrella chart for the team-devoops microservices platform +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/infra/helm/team-devoops/templates/_helpers.tpl b/infra/helm/team-devoops/templates/_helpers.tpl new file mode 100644 index 0000000..0d7a7de --- /dev/null +++ b/infra/helm/team-devoops/templates/_helpers.tpl @@ -0,0 +1,30 @@ +{{/* +Common labels applied to every object. +*/}} +{{- define "team-devoops.labels" -}} +app.kubernetes.io/name: {{ .name }} +app.kubernetes.io/part-of: team-devoops +app.kubernetes.io/managed-by: {{ .root.Release.Service }} +helm.sh/chart: {{ .root.Chart.Name }}-{{ .root.Chart.Version }} +{{- end -}} + +{{/* +Selector labels (stable subset used by Services and Deployment selectors). +*/}} +{{- define "team-devoops.selectorLabels" -}} +app.kubernetes.io/name: {{ .name }} +app.kubernetes.io/part-of: team-devoops +{{- end -}} + +{{/* +Resolve the container image for a service. +Uses an explicit per-service image (external images such as swagger-ui) when set, +otherwise builds // from the global image config. +*/}} +{{- define "team-devoops.image" -}} +{{- $svc := .svc -}} +{{- $g := .root.Values.global.image -}} +{{- $repo := $svc.image | default (printf "%s/%s/%s" $g.registry $g.repository .name) -}} +{{- $tag := $svc.tag | default $g.tag -}} +{{- printf "%s:%s" $repo $tag -}} +{{- end -}} diff --git a/infra/helm/team-devoops/templates/configmap-db.yaml b/infra/helm/team-devoops/templates/configmap-db.yaml new file mode 100644 index 0000000..4aa676b --- /dev/null +++ b/infra/helm/team-devoops/templates/configmap-db.yaml @@ -0,0 +1,12 @@ +{{- if .Values.database.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: db-config + labels: + {{- include "team-devoops.labels" (dict "name" "db-config" "root" $) | nindent 4 }} +data: + SPRING_DATASOURCE_URL: jdbc:postgresql://{{ .Values.database.host }}:{{ .Values.database.port }}/{{ .Values.database.name }} + SPRING_DATASOURCE_USERNAME: {{ .Values.database.user | quote }} + SPRING_JPA_HIBERNATE_DDL_AUTO: update +{{- end }} diff --git a/infra/helm/team-devoops/templates/deployment.yaml b/infra/helm/team-devoops/templates/deployment.yaml new file mode 100644 index 0000000..4625437 --- /dev/null +++ b/infra/helm/team-devoops/templates/deployment.yaml @@ -0,0 +1,73 @@ +{{- range $name, $svc := .Values.services }} +{{- $root := $ }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $name }} + labels: + {{- include "team-devoops.labels" (dict "name" $name "root" $root) | nindent 4 }} +spec: + replicas: {{ $svc.replicas | default 1 }} + selector: + matchLabels: + {{- include "team-devoops.selectorLabels" (dict "name" $name) | nindent 6 }} + template: + metadata: + labels: + {{- include "team-devoops.selectorLabels" (dict "name" $name) | nindent 8 }} + spec: + {{- with $root.Values.global.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ $name }} + image: {{ include "team-devoops.image" (dict "name" $name "svc" $svc "root" $root) }} + imagePullPolicy: {{ $root.Values.global.imagePullPolicy }} + ports: + - containerPort: {{ $svc.port }} + {{- $env := $svc.env | default dict }} + {{- if $env }} + env: + {{- range $k, $v := $env }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + {{- end }} + {{- if or $svc.db $svc.envFromSecret }} + envFrom: + {{- if $svc.db }} + - configMapRef: + name: db-config + - secretRef: + name: db-credentials + {{- end }} + {{- if $svc.envFromSecret }} + - secretRef: + name: {{ $svc.envFromSecret }} + {{- end }} + {{- end }} + {{- if $svc.health }} + readinessProbe: + httpGet: + path: {{ $svc.health }} + port: {{ $svc.port }} + initialDelaySeconds: 15 + periodSeconds: 10 + livenessProbe: + httpGet: + path: {{ $svc.health }} + port: {{ $svc.port }} + initialDelaySeconds: 60 + periodSeconds: 20 + {{- else }} + readinessProbe: + tcpSocket: + port: {{ $svc.port }} + initialDelaySeconds: 15 + periodSeconds: 10 + {{- end }} + resources: + {{- toYaml ($svc.resources | default $root.Values.resources) | nindent 12 }} +--- +{{- end }} diff --git a/infra/helm/team-devoops/templates/ingress.yaml b/infra/helm/team-devoops/templates/ingress.yaml new file mode 100644 index 0000000..d7abe03 --- /dev/null +++ b/infra/helm/team-devoops/templates/ingress.yaml @@ -0,0 +1,78 @@ +{{- if .Values.ingress.enabled }} +{{- $host := .Values.ingress.host }} +{{- $tls := .Values.ingress.tls }} +# --------------------------------------------------------------------------- +# Stripped ingress: services whose path prefix must be removed before the +# request reaches the backend (Traefik stripPrefix parity). Uses a regex +# capture group so `/api/v1/members/foo` -> `/foo`. +# --------------------------------------------------------------------------- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: team-devoops-stripped + labels: + {{- include "team-devoops.labels" (dict "name" "ingress-stripped" "root" $) | nindent 4 }} + annotations: + nginx.ingress.kubernetes.io/use-regex: "true" + nginx.ingress.kubernetes.io/rewrite-target: /$2 +spec: + ingressClassName: {{ .Values.ingress.className }} + {{- if $tls.enabled }} + tls: + - hosts: + - {{ $host | quote }} + {{- if $tls.secretName }} + secretName: {{ $tls.secretName }} + {{- end }} + {{- end }} + rules: + - host: {{ $host | quote }} + http: + paths: + {{- range $name, $svc := .Values.services }} + {{- if $svc.stripPrefix }} + - path: {{ printf "%s(/|$)(.*)" $svc.path }} + pathType: ImplementationSpecific + backend: + service: + name: {{ $name }} + port: + number: {{ $svc.port }} + {{- end }} + {{- end }} +--- +# --------------------------------------------------------------------------- +# Plain ingress: services served at their path as-is (web-client, api-docs). +# --------------------------------------------------------------------------- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: team-devoops-plain + labels: + {{- include "team-devoops.labels" (dict "name" "ingress-plain" "root" $) | nindent 4 }} +spec: + ingressClassName: {{ .Values.ingress.className }} + {{- if $tls.enabled }} + tls: + - hosts: + - {{ $host | quote }} + {{- if $tls.secretName }} + secretName: {{ $tls.secretName }} + {{- end }} + {{- end }} + rules: + - host: {{ $host | quote }} + http: + paths: + {{- range $name, $svc := .Values.services }} + {{- if not $svc.stripPrefix }} + - path: {{ $svc.path }} + pathType: Prefix + backend: + service: + name: {{ $name }} + port: + number: {{ $svc.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/infra/helm/team-devoops/templates/postgres-service.yaml b/infra/helm/team-devoops/templates/postgres-service.yaml new file mode 100644 index 0000000..653f2f3 --- /dev/null +++ b/infra/helm/team-devoops/templates/postgres-service.yaml @@ -0,0 +1,16 @@ +{{- if .Values.database.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.database.host }} + labels: + {{- include "team-devoops.labels" (dict "name" .Values.database.host "root" $) | nindent 4 }} +spec: + type: ClusterIP + selector: + {{- include "team-devoops.selectorLabels" (dict "name" .Values.database.host) | nindent 4 }} + ports: + - port: {{ .Values.database.port }} + targetPort: {{ .Values.database.port }} + protocol: TCP +{{- end }} diff --git a/infra/helm/team-devoops/templates/postgres-statefulset.yaml b/infra/helm/team-devoops/templates/postgres-statefulset.yaml new file mode 100644 index 0000000..180423f --- /dev/null +++ b/infra/helm/team-devoops/templates/postgres-statefulset.yaml @@ -0,0 +1,61 @@ +{{- if .Values.database.enabled }} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ .Values.database.host }} + labels: + {{- include "team-devoops.labels" (dict "name" .Values.database.host "root" $) | nindent 4 }} +spec: + serviceName: {{ .Values.database.host }} + replicas: 1 + selector: + matchLabels: + {{- include "team-devoops.selectorLabels" (dict "name" .Values.database.host) | nindent 6 }} + template: + metadata: + labels: + {{- include "team-devoops.selectorLabels" (dict "name" .Values.database.host) | nindent 8 }} + spec: + containers: + - name: postgres + image: {{ .Values.database.image }} + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + ports: + - containerPort: {{ .Values.database.port }} + env: + - name: POSTGRES_USER + value: {{ .Values.database.user | quote }} + - name: POSTGRES_DB + value: {{ .Values.database.name | quote }} + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: db-credentials + key: POSTGRES_PASSWORD + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + readinessProbe: + exec: + command: ["pg_isready", "-U", "{{ .Values.database.user }}", "-d", "{{ .Values.database.name }}"] + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + exec: + command: ["pg_isready", "-U", "{{ .Values.database.user }}", "-d", "{{ .Values.database.name }}"] + initialDelaySeconds: 30 + periodSeconds: 20 + resources: + {{- toYaml .Values.database.resources | nindent 12 }} + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + {{- if .Values.global.storageClassName }} + storageClassName: {{ .Values.global.storageClassName }} + {{- end }} + resources: + requests: + storage: {{ .Values.database.storageSize }} +{{- end }} diff --git a/infra/helm/team-devoops/templates/secret-db.yaml b/infra/helm/team-devoops/templates/secret-db.yaml new file mode 100644 index 0000000..e5e8598 --- /dev/null +++ b/infra/helm/team-devoops/templates/secret-db.yaml @@ -0,0 +1,13 @@ +{{- if .Values.database.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: db-credentials + labels: + {{- include "team-devoops.labels" (dict "name" "db-credentials" "root" $) | nindent 4 }} +type: Opaque +stringData: + # Single source of truth shared by Postgres and the Spring services. + SPRING_DATASOURCE_PASSWORD: {{ .Values.database.password | quote }} + POSTGRES_PASSWORD: {{ .Values.database.password | quote }} +{{- end }} diff --git a/infra/helm/team-devoops/templates/service.yaml b/infra/helm/team-devoops/templates/service.yaml new file mode 100644 index 0000000..0905c67 --- /dev/null +++ b/infra/helm/team-devoops/templates/service.yaml @@ -0,0 +1,17 @@ +{{- range $name, $svc := .Values.services }} +apiVersion: v1 +kind: Service +metadata: + name: {{ $name }} + labels: + {{- include "team-devoops.labels" (dict "name" $name "root" $) | nindent 4 }} +spec: + type: ClusterIP + selector: + {{- include "team-devoops.selectorLabels" (dict "name" $name) | nindent 4 }} + ports: + - port: {{ $svc.port }} + targetPort: {{ $svc.port }} + protocol: TCP +--- +{{- end }} diff --git a/infra/helm/team-devoops/values.yaml b/infra/helm/team-devoops/values.yaml new file mode 100644 index 0000000..5ec0958 --- /dev/null +++ b/infra/helm/team-devoops/values.yaml @@ -0,0 +1,125 @@ +# Default values for the team-devoops umbrella chart. +# Each microservice is rendered from the generic templates below by ranging over +# `.Values.services`. Adding a service is just a new entry in that map. + +global: + image: + registry: ghcr.io + # GitHub owner/repo (lowercased). Per-service image is //. + repository: aet-devops26/team-devoops + # Overridden per deploy with `--set global.image.tag=`. + tag: latest + imagePullPolicy: IfNotPresent + # Empty => use the cluster's default StorageClass (csi-rbd-sc on the TUM RKE2 cluster). + storageClassName: "" + # Names of pre-created docker-registry Secrets used to pull private ghcr images. + imagePullSecrets: + - name: ghcr-pull + +# Shared in-cluster Postgres database (mirrors the single compose member_db). +database: + enabled: true + name: member_db + user: member_user + # Rendered into a Secret (db-credentials). Override in CI/prod via --set. + password: member_password + host: member-database + port: 5432 + image: postgres:15.6-alpine + storageSize: 5Gi + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: "1" + memory: 512Mi + +# py-genai-helper reads its configuration from a Secret created out-of-band by the +# pipeline (kubectl create secret --from-env-file). Helm only references it by name. +genai: + secretName: genai-env + +ingress: + enabled: true + className: nginx + host: ge83mom-devops26.stud.k8s.aet.cit.tum.de + tls: + enabled: false + secretName: "" + +# Default compute resources applied to every app container (overridable per service). +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + +# --------------------------------------------------------------------------- +# Service catalogue. Keys are the Kubernetes object names. +# path -> ingress path prefix +# port -> container/Service port +# db -> inject SPRING_DATASOURCE_* env (ConfigMap + Secret) +# health -> HTTP readiness/liveness path (omit => TCP probe) +# stripPrefix -> strip the path prefix before forwarding (Traefik parity) +# image/tag -> optional: override the default ghcr image/tag for this service +# env -> extra literal environment variables +# envFromSecret -> inject all keys of an existing Secret as env +# --------------------------------------------------------------------------- +services: + organization-service: + path: /api/v1/organization + port: 8080 + db: true + health: /actuator/health + stripPrefix: true + member-service: + path: /api/v1/members + port: 8080 + db: true + health: /actuator/health + stripPrefix: true + event-service: + path: /api/v1/events + port: 8080 + db: true + health: /actuator/health + stripPrefix: true + feedback-service: + path: /api/v1/feedback + port: 8080 + db: true + health: /actuator/health + stripPrefix: true + finance-service: + path: /api/v1/finance + port: 8080 + db: true + health: /actuator/health + stripPrefix: true + letter-service: + path: /api/v1/letters + port: 8080 + db: true + health: /actuator/health + stripPrefix: true + py-genai-helper: + path: /api/v1/helper + port: 5000 + db: false + health: /health + stripPrefix: true + envFromSecret: genai-env + web-client: + path: / + port: 8080 + db: false + health: / + stripPrefix: false + api-docs: + path: /docs + port: 8080 + db: false + stripPrefix: false 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', })