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',
})