From 962c5a1b3692fb27f003bda2612ffd5ed38d7ffb Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 19 May 2026 19:46:49 +0200 Subject: [PATCH] RAILIANCE-WP-0002 T05+T06 done: vergabe-teilnahme is live at https://vergabe-teilnahme.whywhynot.de MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin Helm chart in charts/vergabe-teilnahme (Deployment + Service), plain values overlay in helm/vergabe-teilnahme-values.yaml, ingress + cert-manager TLS in manifests/vergabe-teilnahme-ingress.yaml. Makefile targets vergabe-dry-run|deploy|ingress-deploy|status|migrate|seed|superuser|logs. Secrets stay in K8s (vergabe-app-credentials + vergabe-teilnahme-env) — no SOPS needed. Live: pod Running 1/1, /health/ 200 ok, /ausschreibungen/dashboard/ renders Übersicht, /admin/login/ renders Django admin (German). cert-manager issued vergabe-teilnahme-tls in ~35s. Workplan T07 (migrate+seed+smoke) marked in_progress; migrate completed inline (10+ apps migrated) so the dashboard would render. Co-Authored-By: Claude Opus 4.7 --- Makefile | 38 ++++++++- charts/vergabe-teilnahme/Chart.yaml | 17 ++++ .../vergabe-teilnahme/templates/_helpers.tpl | 28 +++++++ .../templates/deployment.yaml | 81 +++++++++++++++++++ .../vergabe-teilnahme/templates/service.yaml | 27 +++++++ charts/vergabe-teilnahme/values.yaml | 71 ++++++++++++++++ helm/vergabe-teilnahme-values.yaml | 11 +++ manifests/vergabe-teilnahme-ingress.yaml | 28 +++++++ ...P-0002-vergabe-teilnahme-on-railiance01.md | 45 +++++++++-- 9 files changed, 338 insertions(+), 8 deletions(-) create mode 100644 charts/vergabe-teilnahme/Chart.yaml create mode 100644 charts/vergabe-teilnahme/templates/_helpers.tpl create mode 100644 charts/vergabe-teilnahme/templates/deployment.yaml create mode 100644 charts/vergabe-teilnahme/templates/service.yaml create mode 100644 charts/vergabe-teilnahme/values.yaml create mode 100644 helm/vergabe-teilnahme-values.yaml create mode 100644 manifests/vergabe-teilnahme-ingress.yaml diff --git a/Makefile b/Makefile index f1d9b1f..ba70319 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,12 @@ GITEA_VALUES ?= helm/gitea-values.sops.yaml GITEA_REGISTRY_VALUES ?= helm/gitea-registry-values.yaml GITEA_INGRESS ?= manifests/gitea-ingress.yaml +VERGABE_RELEASE ?= vergabe-teilnahme +VERGABE_NAMESPACE ?= vergabe-teilnahme +VERGABE_CHART ?= charts/vergabe-teilnahme +VERGABE_VALUES ?= helm/vergabe-teilnahme-values.yaml +VERGABE_INGRESS ?= manifests/vergabe-teilnahme-ingress.yaml + ##@ Gitea gitea-deploy: ## Deploy / upgrade Gitea (S5 workload) @@ -25,6 +31,36 @@ gitea-status: ## Check Gitea health kubectl get ingress -n $(GITEA_NAMESPACE) $(GITEA_RELEASE) --ignore-not-found kubectl cnpg status gitea-db -n databases +##@ Vergabe Teilnahme + +vergabe-dry-run: ## helm template render (no apply) for inspection + helm template $(VERGABE_RELEASE) $(VERGABE_CHART) \ + --namespace $(VERGABE_NAMESPACE) \ + -f $(VERGABE_VALUES) + +vergabe-deploy: ## Deploy / upgrade vergabe-teilnahme Helm release + helm upgrade --install $(VERGABE_RELEASE) $(VERGABE_CHART) \ + --namespace $(VERGABE_NAMESPACE) --create-namespace \ + -f $(VERGABE_VALUES) --wait --timeout 3m + +vergabe-ingress-deploy: ## Apply the vergabe-teilnahme ingress (whywhynot.de) + kubectl apply -f $(VERGABE_INGRESS) + +vergabe-status: ## Show vergabe-teilnahme pod / svc / ingress / cert state + kubectl get pods,svc,ingress,certificate -n $(VERGABE_NAMESPACE) -l app.kubernetes.io/instance=$(VERGABE_RELEASE) --ignore-not-found + +vergabe-migrate: ## Run Django migrations against the live deployment + kubectl exec -n $(VERGABE_NAMESPACE) deploy/$(VERGABE_RELEASE) -- python manage.py migrate --noinput + +vergabe-seed: ## Run the idempotent seed command + kubectl exec -n $(VERGABE_NAMESPACE) deploy/$(VERGABE_RELEASE) -- python manage.py seed_dev + +vergabe-superuser: ## Open an interactive shell for createsuperuser + kubectl exec -it -n $(VERGABE_NAMESPACE) deploy/$(VERGABE_RELEASE) -- python manage.py createsuperuser + +vergabe-logs: ## Tail vergabe-teilnahme app logs + kubectl logs -n $(VERGABE_NAMESPACE) -l app.kubernetes.io/instance=$(VERGABE_RELEASE) -f --tail=50 + ##@ Help help: ## Show this help @@ -32,4 +68,4 @@ help: ## Show this help /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 } \ /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST) -.PHONY: gitea-deploy gitea-ingress-deploy gitea-status help +.PHONY: gitea-deploy gitea-ingress-deploy gitea-status vergabe-dry-run vergabe-deploy vergabe-ingress-deploy vergabe-status vergabe-migrate vergabe-seed vergabe-superuser vergabe-logs help diff --git a/charts/vergabe-teilnahme/Chart.yaml b/charts/vergabe-teilnahme/Chart.yaml new file mode 100644 index 0000000..832a3c1 --- /dev/null +++ b/charts/vergabe-teilnahme/Chart.yaml @@ -0,0 +1,17 @@ +apiVersion: v2 +name: vergabe-teilnahme +description: | + Vergabe Teilnahme — internal Django tender/bid management web app. + Single-instance v1 deployment; HA and canary are deferred. +type: application +version: 0.1.0 +appVersion: "0.1.0" +keywords: + - django + - vergabe + - railiance +home: https://gitea.coulomb.social/coulomb/vergabe-teilnahme +sources: + - https://gitea.coulomb.social/coulomb/vergabe-teilnahme +maintainers: + - name: railiance-apps diff --git a/charts/vergabe-teilnahme/templates/_helpers.tpl b/charts/vergabe-teilnahme/templates/_helpers.tpl new file mode 100644 index 0000000..d044502 --- /dev/null +++ b/charts/vergabe-teilnahme/templates/_helpers.tpl @@ -0,0 +1,28 @@ +{{/* +Chart name + release name produce a unique resource name. +*/}} +{{- define "vergabe.fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s" $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "vergabe.labels" -}} +app.kubernetes.io/name: {{ include "vergabe.fullname" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/part-of: railiance-apps +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +{{- end -}} + +{{- define "vergabe.selectorLabels" -}} +app.kubernetes.io/name: {{ include "vergabe.fullname" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{- define "vergabe.image" -}} +{{- if not .Values.image.tag -}} +{{- fail "image.tag is required — pin it in helm/vergabe-teilnahme-values.yaml" -}} +{{- end -}} +{{- printf "%s:%s" .Values.image.repository .Values.image.tag -}} +{{- end -}} diff --git a/charts/vergabe-teilnahme/templates/deployment.yaml b/charts/vergabe-teilnahme/templates/deployment.yaml new file mode 100644 index 0000000..ce55216 --- /dev/null +++ b/charts/vergabe-teilnahme/templates/deployment.yaml @@ -0,0 +1,81 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "vergabe.fullname" . }} + labels: {{- include "vergabe.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: {{- include "vergabe.selectorLabels" . | nindent 6 }} + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: {{- include "vergabe.selectorLabels" . | nindent 8 }} + spec: + securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: app + image: {{ include "vergabe.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: {{- toYaml .Values.securityContext | nindent 12 }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + envFrom: + - secretRef: + name: {{ .Values.envSecretName | quote }} + env: + {{- range $k, $v := .Values.env }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + {{- if .Values.probes.enabled }} + readinessProbe: + httpGet: + path: {{ .Values.probes.path }} + port: {{ .Values.probes.port }} + httpHeaders: + - name: Host + value: {{ .Values.probes.hostHeader | quote }} + initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }} + periodSeconds: {{ .Values.probes.readiness.periodSeconds }} + timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }} + failureThreshold: {{ .Values.probes.readiness.failureThreshold }} + livenessProbe: + httpGet: + path: {{ .Values.probes.path }} + port: {{ .Values.probes.port }} + httpHeaders: + - name: Host + value: {{ .Values.probes.hostHeader | quote }} + initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }} + periodSeconds: {{ .Values.probes.liveness.periodSeconds }} + timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }} + failureThreshold: {{ .Values.probes.liveness.failureThreshold }} + {{- end }} + resources: {{- toYaml .Values.resources | nindent 12 }} + {{- if .Values.persistence.media.enabled }} + volumeMounts: + - name: media + mountPath: /app/media + {{- end }} + {{- if .Values.persistence.media.enabled }} + volumes: + - name: media + persistentVolumeClaim: + claimName: {{ include "vergabe.fullname" . }}-media + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/vergabe-teilnahme/templates/service.yaml b/charts/vergabe-teilnahme/templates/service.yaml new file mode 100644 index 0000000..b08fb10 --- /dev/null +++ b/charts/vergabe-teilnahme/templates/service.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "vergabe.fullname" . }} + labels: {{- include "vergabe.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + protocol: TCP + name: http + selector: {{- include "vergabe.selectorLabels" . | nindent 4 }} +{{- if .Values.persistence.media.enabled }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "vergabe.fullname" . }}-media + labels: {{- include "vergabe.labels" . | nindent 4 }} +spec: + storageClassName: {{ .Values.persistence.media.storageClass }} + accessModes: [{{ .Values.persistence.media.accessMode }}] + resources: + requests: + storage: {{ .Values.persistence.media.size }} +{{- end }} diff --git a/charts/vergabe-teilnahme/values.yaml b/charts/vergabe-teilnahme/values.yaml new file mode 100644 index 0000000..876b813 --- /dev/null +++ b/charts/vergabe-teilnahme/values.yaml @@ -0,0 +1,71 @@ +image: + repository: gitea.coulomb.social/coulomb/vergabe-teilnahme + tag: "" # required; pinned via helm/vergabe-teilnahme-values.yaml + pullPolicy: IfNotPresent + +replicaCount: 1 # v1 is single-instance; HA is deferred (RAILIANCE-WP-0002 Notes) + +service: + type: ClusterIP + port: 80 + targetPort: 8000 + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 1000m + memory: 1Gi + +# Env from the K8s Secret created out-of-band (vergabe-teilnahme-env). +# Holds SECRET_KEY + DATABASE_URL. Created by the operator with kubectl +# create secret generic vergabe-teilnahme-env --from-literal=... +envSecretName: vergabe-teilnahme-env + +# Non-secret env injected directly into the Deployment. +env: + DJANGO_SETTINGS_MODULE: vergabe_teilnahme.settings.prod + ALLOWED_HOSTS: vergabe-teilnahme.whywhynot.de,localhost + CSRF_TRUSTED_ORIGINS: https://vergabe-teilnahme.whywhynot.de + +probes: + enabled: true + path: /health/ + port: 8000 + hostHeader: vergabe-teilnahme.whywhynot.de # must be in ALLOWED_HOSTS + liveness: + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readiness: + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + +# PVC for media uploads is deferred — Django MEDIA is in-pod ephemeral +# for v1. Switch to true + a storageClass once media uploads land. +persistence: + media: + enabled: false + storageClass: local-path + size: 5Gi + accessMode: ReadWriteOnce + +podSecurityContext: + runAsNonRoot: true + runAsUser: 999 # matches the 'app' user in the Dockerfile + runAsGroup: 999 + fsGroup: 999 + +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false # whitenoise + collectstatic write to /app + capabilities: + drop: ["ALL"] + +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/helm/vergabe-teilnahme-values.yaml b/helm/vergabe-teilnahme-values.yaml new file mode 100644 index 0000000..9850faf --- /dev/null +++ b/helm/vergabe-teilnahme-values.yaml @@ -0,0 +1,11 @@ +# Production overrides for the vergabe-teilnahme Helm chart. +# Non-secret values only; SECRET_KEY and DATABASE_URL come from the +# Secret 'vergabe-teilnahme-env' in the vergabe-teilnahme namespace. + +image: + tag: "483a4df" # T03 build (gitea.coulomb.social/coulomb/vergabe-teilnahme) + +env: + DJANGO_SETTINGS_MODULE: vergabe_teilnahme.settings.prod + ALLOWED_HOSTS: vergabe-teilnahme.whywhynot.de,localhost + CSRF_TRUSTED_ORIGINS: https://vergabe-teilnahme.whywhynot.de diff --git a/manifests/vergabe-teilnahme-ingress.yaml b/manifests/vergabe-teilnahme-ingress.yaml new file mode 100644 index 0000000..c0d6712 --- /dev/null +++ b/manifests/vergabe-teilnahme-ingress.yaml @@ -0,0 +1,28 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: vergabe-teilnahme + namespace: vergabe-teilnahme + labels: + app.kubernetes.io/name: vergabe-teilnahme + app.kubernetes.io/instance: vergabe-teilnahme + app.kubernetes.io/part-of: railiance-apps + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + ingressClassName: traefik + rules: + - host: vergabe-teilnahme.whywhynot.de + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: vergabe-teilnahme + port: + number: 80 + tls: + - hosts: + - vergabe-teilnahme.whywhynot.de + secretName: vergabe-teilnahme-tls diff --git a/workplans/railiance-apps-WP-0002-vergabe-teilnahme-on-railiance01.md b/workplans/railiance-apps-WP-0002-vergabe-teilnahme-on-railiance01.md index 0a3c6f0..2958f99 100644 --- a/workplans/railiance-apps-WP-0002-vergabe-teilnahme-on-railiance01.md +++ b/workplans/railiance-apps-WP-0002-vergabe-teilnahme-on-railiance01.md @@ -394,7 +394,7 @@ credentials — returns `vergabe | vergabe_db | PostgreSQL 16.13`. ```task id: RAILIANCE-WP-0002-T05 -status: todo +status: done priority: high state_hub_task_id: "29ba6add-6f23-4053-acb9-9d7efa0b3881" ``` @@ -424,13 +424,40 @@ Deliverables: **Done when:** `make vergabe-deploy` renders cleanly with `--dry-run` and produces no plaintext secrets in the rendered manifest source. +**Done (2026-05-19):** + +- Chart approach: thin in-repo chart `charts/vergabe-teilnahme/` rather + than SOPS-encrypted values, because the only sensitive material + (`SECRET_KEY`, `DATABASE_URL`) lives in K8s Secrets (cnpg's + `vergabe-app-credentials` + the assembled `vergabe-teilnahme-env`), + not in Helm values. `helm/vergabe-teilnahme-values.yaml` is therefore + plain YAML — image tag, hostnames, no secrets. +- `make vergabe-dry-run` renders 2 objects (Deployment + Service); + `grep -iE 'SECRET_KEY=|DATABASE_URL=|password'` returns empty. +- Deploy revision 2 is live: pod Running 1/1, probes green. The + HTTP-probe `httpGet.httpHeaders[Host]` is set to the public hostname + so Django's `ALLOWED_HOSTS` check passes for kube-probe (the v1 + fix took one iteration — earlier attempts failed liveness with HTTP + 400 because the probe sent `Host: 10.42.x.x:8000`). +- `Makefile` targets added: `vergabe-dry-run`, `vergabe-deploy`, + `vergabe-ingress-deploy`, `vergabe-status`, `vergabe-migrate`, + `vergabe-seed`, `vergabe-superuser`, `vergabe-logs`. + +**Lesson recorded:** the base64-generated bootstrap password contains +`=`, `+`, `/`; embedding it raw in `DATABASE_URL` confuses +`dj-database-url` (it parses `:password@host:5432/db` and the `=` +broke the DB name into 80 characters). The Secret now stores a +URL-encoded password inside `DATABASE_URL` while the raw password +remains in `vergabe-app-credentials.password`. Future apps should +either URL-encode at Secret-build time or use individual env vars. + --- ### T06 — DNS, ingress, and TLS for vergabe-teilnahme.whywhynot.de ```task id: RAILIANCE-WP-0002-T06 -status: in_progress +status: done priority: high state_hub_task_id: "8e673ee6-5338-4eb5-8973-a1818b4dc7f5" ``` @@ -464,10 +491,14 @@ certificate chain validates from outside the cluster. (TTL 3600; served authoritatively by `ns1126.ui-dns.*`). - ✅ Traefik routing reaches the cluster: HTTP probe returns 404 — the expected pre-state because no Ingress rule matches the host yet. -- ⏳ `manifests/vergabe-teilnahme-ingress.yaml` — not yet created (waits - on T05's Service to point at; creating the ingress before the backend - Service exists would waste a Let's Encrypt issuance attempt). -- ⏳ `vergabe-teilnahme-tls` Secret — pending ingress. +- ✅ `manifests/vergabe-teilnahme-ingress.yaml` committed; Traefik + + cert-manager letsencrypt-prod. +- ✅ `vergabe-teilnahme-tls` issued by cert-manager in ~35s (HTTP-01). +- ✅ External HTTPS probes: `/health/` returns 200 `{"status":"ok"}`; + `/` redirects (302) to `/ausschreibungen/dashboard/` which renders + `Übersicht` (German UI); `/admin/login/` shows the + German Django admin login page. `curl` reports + `SSL verify_result: 0` (trusted chain). --- @@ -475,7 +506,7 @@ certificate chain validates from outside the cluster. ```task id: RAILIANCE-WP-0002-T07 -status: todo +status: in_progress priority: high state_hub_task_id: "be1decb5-b734-4312-b98d-20ed5299d02c" ```