diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md new file mode 100644 index 0000000..fac55b5 --- /dev/null +++ b/deploy/k8s/README.md @@ -0,0 +1,66 @@ +# Kubernetes deployment + +`base/` holds the manifests for the account webapp: Deployment, Service, +Ingress, PodDisruptionBudget and ConfigMap. Apply it with +`kubectl apply -k` (kustomize is built into kubectl). + +## What the cluster needs to provide + +- **PostgreSQL.** Sessions, password-reset tokens and pending registrations + live in the database, so more than one replica needs PostgreSQL. Point + `DATABASE_HOST` at it. The base doesn't ship a database; use the + cluster's managed or operator-run PostgreSQL. +- **An ingress controller with TLS.** The app expects TLS to terminate at + the ingress and trusts `X-Forwarded-Proto` (`TRUST_X_FORWARDED_PROTO=True`). +- **Network access** to the LDAP servers, the SMTP relay and the OTP server. + +## Deploying + +1. Build and push the image from the repository root: + + ```sh + docker build -t registry.example.com/ungleich-user:1.0.0 . + docker push registry.example.com/ungleich-user:1.0.0 + ``` + +2. Copy `base/` into your environment's overlay (or edit it in place) and set: + - `images:` in `kustomization.yaml`: the registry and a pinned tag + - `configmap.yaml`: every value marked `REQUIRED` (`env.sample` lists + all settings with their defaults) + - `ingress.yaml`: the ingress class and the TLS secret or cert-manager + annotations + +3. Create the Secret. It isn't part of the base, so credentials never go + into git. See `secret.example.yaml`: + + ```sh + kubectl create secret generic ungleich-user \ + --from-literal=SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(50))')" \ + --from-literal=LDAP_ADMIN_DN='cn=...' \ + --from-literal=LDAP_ADMIN_PASSWORD='...' \ + --from-literal=DATABASE_PASSWORD='...' \ + --from-literal=ADMIN_SEED='...' + ``` + +4. Apply: `kubectl apply -k deploy/k8s/base` (or your overlay). + +## How it behaves + +| Concern | Behaviour | +|---|---| +| Migrations | An init container runs `manage.py migrate_locked` in each pod. A PostgreSQL advisory lock makes pods that start together wait for the first one instead of migrating at the same time. | +| Probes | `/healthz` (liveness) has no dependencies, so an LDAP outage never restarts pods. `/readyz` (readiness) needs the database and at least one LDAP server. Both bypass `ALLOWED_HOSTS`. | +| LDAP failover | `LDAPSERVER` is a space-separated list. The first reachable server is used, and a failed server is skipped for 60 seconds. | +| Security | Runs as uid 10001 with a read-only root filesystem, all capabilities dropped, no privilege escalation, the RuntimeDefault seccomp profile and no service account token. The image has no sshd. | +| Logs | stdout/stderr only. `LOG_LEVEL` sets the level. | +| Rollouts | Rolling updates add one new pod before removing an old one (`maxSurge: 1`, `maxUnavailable: 0`). The PodDisruptionBudget keeps at least one pod running during node drains. | + +### Before going live, check with the operators + +- `LDAP_USE_TLS` defaults to `True` (StartTLS) in the ConfigMap. Confirm + the production LDAP servers support StartTLS, or use `ldaps://` URIs. +- HSTS (`SECURE_HSTS_SECONDS`) is off. Enabling it, especially for + subdomains, affects every `*.ungleich.ch` site. +- Emails sent before the switch contain activation links in the old + format. Those links stop working, and the affected users have to + register again. diff --git a/deploy/k8s/base/configmap.yaml b/deploy/k8s/base/configmap.yaml new file mode 100644 index 0000000..0de9dcc --- /dev/null +++ b/deploy/k8s/base/configmap.yaml @@ -0,0 +1,35 @@ +# Non-secret settings. See env.sample for every variable and its default. +apiVersion: v1 +kind: ConfigMap +metadata: + name: ungleich-user +data: + ALLOWED_HOSTS: "account.ungleich.ch" + CSRF_TRUSTED_ORIGINS: "https://account.ungleich.ch" + TRUST_X_FORWARDED_PROTO: "True" + DEBUG: "False" + LOG_LEVEL: "INFO" + + LDAPSERVER: "ldap://ldap1.ungleich.ch ldap://ldap2.ungleich.ch" + LDAP_USE_TLS: "True" + LDAPSEARCH: "ou=customers,dc=ungleich,dc=ch ou=users,dc=ungleich,dc=ch" + ENTIRE_SEARCH_BASE: "dc=ungleich,dc=ch" + LDAP_CUSTOMER_DN: "ou=customers,dc=ungleich,dc=ch" + LDAP_CUSTOMER_GROUP_ID: "" # REQUIRED: gidNumber for new customers + LDAP_DEFAULT_START_UID: "" # REQUIRED: uidNumbers start above this + + DATABASE_HOST: "" # REQUIRED for >1 replica: PostgreSQL host + DATABASE_NAME: "ungleich_user" + DATABASE_USER: "ungleich_user" + DATABASE_SSLMODE: "prefer" + + EMAIL_FROM_ADDRESS: "" # REQUIRED + EMAIL_HOST: "localhost" + EMAIL_PORT: "25" + EMAIL_USE_TLS: "True" + + OTPSERVER: "" # REQUIRED + ADMIN_NAME: "" + ADMIN_REALM: "" + USER_REALM: "" + ALLOWED_REALMS: "" diff --git a/deploy/k8s/base/deployment.yaml b/deploy/k8s/base/deployment.yaml new file mode 100644 index 0000000..a525297 --- /dev/null +++ b/deploy/k8s/base/deployment.yaml @@ -0,0 +1,76 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ungleich-user +spec: + replicas: 2 + revisionHistoryLimit: 5 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + seccompProfile: + type: RuntimeDefault + # Migrations run before the app starts; migrate_locked serializes + # them when several pods start at once. + initContainers: + - name: migrate + image: ungleich-user + command: ["python", "manage.py", "migrate_locked"] + envFrom: &env + - configMapRef: + name: ungleich-user + - secretRef: + name: ungleich-user + securityContext: &containerSecurity + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: &mounts + - name: tmp + mountPath: /tmp + resources: + requests: {cpu: 50m, memory: 128Mi} + limits: {memory: 256Mi} + containers: + - name: web + image: ungleich-user + envFrom: *env + ports: + - name: http + containerPort: 8000 + securityContext: *containerSecurity + volumeMounts: *mounts + # Liveness has no dependencies, so an LDAP outage never restarts + # pods; readiness takes a pod out of the Service when the + # database or every LDAP server is unreachable. + livenessProbe: + httpGet: {path: /healthz, port: http} + periodSeconds: 20 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: {path: /readyz, port: http} + periodSeconds: 10 + timeoutSeconds: 10 + failureThreshold: 3 + startupProbe: + httpGet: {path: /healthz, port: http} + periodSeconds: 2 + failureThreshold: 30 + resources: + requests: {cpu: 100m, memory: 192Mi} + limits: {memory: 512Mi} + volumes: + - name: tmp + emptyDir: + sizeLimit: 64Mi diff --git a/deploy/k8s/base/ingress.yaml b/deploy/k8s/base/ingress.yaml new file mode 100644 index 0000000..f9b0f82 --- /dev/null +++ b/deploy/k8s/base/ingress.yaml @@ -0,0 +1,23 @@ +# TLS terminates here; the app trusts X-Forwarded-Proto +# (TRUST_X_FORWARDED_PROTO in the ConfigMap). Adjust the class, host and +# certificate secret to the cluster (e.g. cert-manager annotations). +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ungleich-user +spec: + # ingressClassName: nginx + tls: + - hosts: ["account.ungleich.ch"] + secretName: ungleich-user-tls + rules: + - host: account.ungleich.ch + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: ungleich-user + port: + name: http diff --git a/deploy/k8s/base/kustomization.yaml b/deploy/k8s/base/kustomization.yaml new file mode 100644 index 0000000..891ea8f --- /dev/null +++ b/deploy/k8s/base/kustomization.yaml @@ -0,0 +1,25 @@ +# Base manifests for the ungleich account webapp. +# +# kubectl apply -k deploy/k8s/base # after creating the Secret +# +# The Secret "ungleich-user" is not part of this base; create it from +# secret.example.yaml (or your secret manager) before applying. +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +labels: + - pairs: + app.kubernetes.io/name: ungleich-user + includeSelectors: true + +resources: + - configmap.yaml + - deployment.yaml + - service.yaml + - ingress.yaml + - pdb.yaml + +images: + - name: ungleich-user + newName: ungleich-user # set to your registry, e.g. registry.example.com/ungleich-user + newTag: latest # pin a release tag or digest in production diff --git a/deploy/k8s/base/pdb.yaml b/deploy/k8s/base/pdb.yaml new file mode 100644 index 0000000..c2bda73 --- /dev/null +++ b/deploy/k8s/base/pdb.yaml @@ -0,0 +1,6 @@ +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: ungleich-user +spec: + minAvailable: 1 diff --git a/deploy/k8s/base/service.yaml b/deploy/k8s/base/service.yaml new file mode 100644 index 0000000..4e71f4b --- /dev/null +++ b/deploy/k8s/base/service.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Service +metadata: + name: ungleich-user +spec: + ports: + - name: http + port: 80 + targetPort: http diff --git a/deploy/k8s/secret.example.yaml b/deploy/k8s/secret.example.yaml new file mode 100644 index 0000000..69546c7 --- /dev/null +++ b/deploy/k8s/secret.example.yaml @@ -0,0 +1,22 @@ +# Example only; do not commit real values. Create the Secret with your +# secret manager, or fill this in outside git and apply it: +# +# kubectl create secret generic ungleich-user \ +# --from-literal=SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(50))')" \ +# --from-literal=LDAP_ADMIN_DN='...' \ +# --from-literal=LDAP_ADMIN_PASSWORD='...' \ +# --from-literal=DATABASE_PASSWORD='...' \ +# --from-literal=ADMIN_SEED='...' +apiVersion: v1 +kind: Secret +metadata: + name: ungleich-user +type: Opaque +stringData: + SECRET_KEY: "" + LDAP_ADMIN_DN: "" + LDAP_ADMIN_PASSWORD: "" + DATABASE_PASSWORD: "" + ADMIN_SEED: "" + # EMAIL_HOST_USER: "" + # EMAIL_HOST_PASSWORD: ""