feat: deploy

This commit is contained in:
Daniel Noulet 2026-10-03 04:39:27 +02:00
parent 9bbf8a96aa
commit 842f8b727e
6 changed files with 620 additions and 0 deletions

View file

@ -0,0 +1,272 @@
# Go Guess Helm chart
This chart deploys Go Guess as two images, an API and a web front end, plus its
own PostgreSQL and RabbitMQ, and publishes one HTTPS host per tenant under
`https://<tenant>.guess.local`.
## 1. What the chart creates
| Resource | Name | Purpose |
|---|---|---|
| Deployment | `go-guess-api` | Go API, runs migrations and seeds before serving |
| Deployment | `go-guess-web` | nginx serving the built React application |
| Service | `api` | Cluster-internal API, proxied by the web image |
| Service | `go-guess-web` | Cluster-internal web front end, ingress backend |
| Ingress | `go-guess` | One rule and one TLS SAN per tenant host |
| Issuer | `go-guess-selfsigned` | cert-manager self-signed issuer |
| Certificate | `go-guess-web-ca` | Chart CA that signs the leaf certificate |
| Certificate | `go-guess-web` | Leaf certificate covering every tenant host |
| StatefulSet | `go-guess-postgresql` | PostgreSQL, `emptyDir` or persistent volume |
| StatefulSet | `go-guess-rabbitmq` | RabbitMQ, `emptyDir` or persistent volume |
| Secret | `go-guess-secrets` | JWT secret, connection URLs, broker credentials |
A single web deployment serves every tenant host. The API resolves the tenant
from the request `Host` header, so adding a tenant means adding one entry to
`tenants`, not deploying another stack.
## 2. Prerequisites
- A Kubernetes cluster reachable through `KUBECONFIG`.
- A `StorageClass` when `postgresql.persistence.enabled` or
`rabbitmq.persistence.enabled` is `true`.
- ingress-nginx with the `nginx` ingress class: `make ingress-nginx-install`.
- cert-manager when `ingress.tls.enabled` is `true`: `make cert-manager-install`.
- Cluster nodes trusting `batty1039.startdedicated.net:5000` as an insecure
registry, or an HTTPS registry.
Run everything the chart depends on in one step:
```bash
make cluster-prepare
```
## 3. Deploy
```bash
make helm-lint
make helm-deploy-development
```
The development profile uses `latest` images, development fixtures, ephemeral
storage, and namespace `go-insane-development`. Production uses released tags,
production fixtures, persistent volumes, and namespace `go-insane-production`:
```bash
make helm-deploy-production
```
Both targets accept per-tenant Go Loose credentials from the environment:
```bash
export GO_LOOSE_NMBS_CLIENT_ID=...
export GO_LOOSE_NMBS_CLIENT_SECRET=...
export GO_LOOSE_YPTO_CLIENT_ID=...
export GO_LOOSE_YPTO_CLIENT_SECRET=...
make helm-deploy-production
```
After a successful install the release notes list every tenant URL and the
command that exports the CA certificate. With the local platform, prefer
`docs/KUBERNETES-DEPLOYMENTS.md` in the infrastructure repository, which covers
`/etc/hosts` entries and CA trust for the whole platform.
## 4. Tenants and hosts
`tenants` is the list of slugs and `domain` is the shared parent domain:
```yaml
domain: guess.local
tenants:
- nmbs
- ypto
```
The chart derives, without any extra configuration:
- `GO_LOOSE_APP_DOMAIN=guess.local`, so the API accepts `nmbs.guess.local` and
`ypto.guess.local` as tenant hosts.
- One ingress rule per tenant, all pointing at `go-guess-web`.
- One certificate SAN per tenant, so a single secret covers every host.
- `FRONTEND_URL`, when `api.frontendURL` is empty, as
`https://<first tenant>.<domain>`. This is the fallback used to build
invitation and meeting links, so the first entry is the tenant that receives
shareable links.
A slug must be a single DNS label. The API rejects slugs containing a dot, and
the certificate would not match a two-level host.
Adding a tenant therefore requires three things: the slug in `tenants`, a DNS
record pointing at the ingress controller, and a Go Loose client when browser
sign-in is used.
## 5. Values
### Top level
| Key | Default | Description |
|---|---|---|
| `nameOverride` | `""` | Overrides the chart name in resource names and labels |
| `fullnameOverride` | `""` | Replaces the generated resource name prefix |
| `domain` | `guess.local` | Parent domain of every tenant host |
| `tenants` | `[nmbs, ypto]` | Tenant slugs served by the release |
| `imagePullSecrets` | `[]` | Pull secrets added to both pods |
### api
| Key | Default | Description |
|---|---|---|
| `api.replicaCount` | `1` | API replicas, keep at `1` while migrations run on startup |
| `api.image.repository` | `batty1039.startdedicated.net:5000/go-guess-api` | API image |
| `api.image.tag` | `0.0.1` | API image tag |
| `api.image.pullPolicy` | `IfNotPresent` | API pull policy |
| `api.environment` | `development` | `APP_ENV`, `production` seeds production fixtures only |
| `api.frontendURL` | `""` | Fallback public URL for invitation and meeting links |
| `api.tokenTTL` | `8h` | `TOKEN_TTL` for issued API tokens |
| `api.maxUploadMB` | `10` | `MAX_UPLOAD_MB` upload limit, match `proxy-body-size` |
| `api.service.name` | `api` | Service DNS name, fixed by the web image |
| `api.service.type` | `ClusterIP` | API service type |
| `api.service.port` | `8080` | API service port |
| `api.goLoose.enabled` | `false` | Enables Go Loose browser sign-in |
| `api.goLoose.authDomain` | `auth.dev` | Tenant login host `https://<tenant>.<authDomain>` |
| `api.goLoose.ca.existingSecret` | `""` | Secret holding the Go Loose CA bundle |
| `api.goLoose.ca.key` | `ca.crt` | Key projected from that secret |
| `api.goLoose.ca.mountPath` | `/certs/local-ca.crt` | `GO_LOOSE_CA_FILE` target |
| `api.goLoose.tenants.<slug>.clientID` | `""` | Go Loose client ID for that tenant |
| `api.goLoose.tenants.<slug>.clientSecret` | `""` | Go Loose client secret for that tenant |
| `api.resources` | `50m`/`128Mi` to `500m`/`512Mi` | API requests and limits |
| `api.podAnnotations`, `api.podLabels` | `{}` | Extra pod metadata |
| `api.podSecurityContext` | `{}` | Pod security context |
| `api.securityContext` | non-root, read-only root filesystem | Container security context |
| `api.strategy.type` | `Recreate` | Old pod exits before migrations run again |
### web
| Key | Default | Description |
|---|---|---|
| `web.replicaCount` | `2` | nginx replicas, safe to scale because nginx is stateless |
| `web.image.repository` | `batty1039.startdedicated.net:5000/go-guess-web` | Web image |
| `web.image.tag` | `0.0.1` | Web image tag |
| `web.image.pullPolicy` | `IfNotPresent` | Web pull policy |
| `web.service.type` | `ClusterIP` | Web service type |
| `web.service.port` | `80` | Web service port |
| `web.service.nodePort` | `null` | Node port when the type is `NodePort` |
| `web.resources` | `25m`/`32Mi` to `300m`/`128Mi` | Web requests and limits |
| `web.podAnnotations`, `web.podLabels` | `{}` | Extra pod metadata |
| `web.podSecurityContext` | `{}` | Pod security context |
| `web.securityContext` | root, `NET_BIND_SERVICE` only | Container security context |
| `web.strategy` | rolling, `maxUnavailable: 0` | Keeps the ingress backed up during rollout |
Neither container has a forced uid. Both images declare their own user, the API
as `app` and nginx as `nginx`, and `runAsNonRoot` validates it.
The web security context differs from the API one on purpose. nginx binds the
privileged port `80` and forks workers as the `nginx` user, so the pod runs as
root with `CHOWN`, `SETGID`, `SETUID`, and `NET_BIND_SERVICE`, and mounts
writable `emptyDir` volumes at `/var/cache/nginx`, `/var/run`, and `/tmp`.
### ingress
| Key | Default | Description |
|---|---|---|
| `ingress.enabled` | `true` | Creates the multi-host ingress |
| `ingress.className` | `nginx` | Ingress class of the controller |
| `ingress.annotations` | body size `10m`, timeouts `3600`, buffering off, TLS redirect | Controller annotations |
| `ingress.path` | `/` | Path routed to the web service |
| `ingress.pathType` | `Prefix` | Path type of that route |
| `ingress.tls.enabled` | `true` | Terminates TLS on the controller |
| `ingress.tls.secretName` | `""` | Defaults to `<fullname>-web-tls` |
| `ingress.tls.existingSecret` | `""` | Uses a certificate managed outside the chart |
The proxy annotations matter for the interview feature: the API streams events
over server-sent events and accepts video uploads, so buffering off, a one-hour
read timeout, and a body size at least as large as `api.maxUploadMB` are
required, otherwise streams stall and uploads fail with `413`.
### certManager
| Key | Default | Description |
|---|---|---|
| `certManager.enabled` | `true` | Chart creates the certificates |
| `certManager.issuerRef` | `{}` | Uses an existing issuer instead of the chart CA |
| `certManager.duration` | `8760h` | Certificate lifetime |
| `certManager.renewBefore` | `720h` | Renewal window |
| `certManager.caSecretName` | `""` | Defaults to `<fullname>-web-ca` |
Three certificate objects are created in order: a self-signed `Issuer`, a CA
`Certificate` stored in `caSecretName`, and the leaf `Certificate` stored in the
ingress TLS secret with one `dnsNames` entry per tenant. Set `issuerRef` to sign
with a cluster issuer instead, for example an internal ACME issuer:
```yaml
certManager:
issuerRef:
name: internal-acme
kind: ClusterIssuer
group: cert-manager.io
```
Set `ingress.tls.existingSecret` to use a certificate the chart does not own,
and `certManager.enabled: false` to stop emitting cert-manager objects.
### Secrets, storage, and scheduling
| Key | Default | Description |
|---|---|---|
| `secrets.jwtSecret` | `""` | Generated and preserved across upgrades when empty |
| `secrets.databaseURL` | derived | Full URL, required when PostgreSQL is disabled |
| `secrets.rabbitmqURL` | derived | Full URL, required when RabbitMQ is disabled |
| `secrets.postgresqlPassword` | generated | PostgreSQL user password |
| `secrets.rabbitmqPassword` | generated | RabbitMQ user password |
| `postgresql.enabled` | `true` | Bundled PostgreSQL |
| `postgresql.persistence.enabled` | `true` | Persistent volume instead of `emptyDir` |
| `postgresql.persistence.size` | `8Gi` | Volume size |
| `postgresql.auth.username` | `go_guess` | Database user |
| `rabbitmq.enabled` | `true` | Bundled RabbitMQ |
| `rabbitmq.persistence.enabled` | `true` | Persistent volume instead of `emptyDir` |
| `rabbitmq.persistence.size` | `4Gi` | Volume size |
| `nodeSelector`, `tolerations`, `affinity` | empty | Applied to both deployments |
Generated secrets are read back with `lookup` on upgrade, so leaving them empty
rotates nothing and keeps sessions valid across releases.
## 6. External services
Disable the bundled StatefulSets and pass full connection URLs to use managed
PostgreSQL and RabbitMQ:
```bash
helm upgrade --install go-guess deploy/helm/go-guess \
--namespace go-insane-production --create-namespace \
-f deploy/helm/go-guess/values-production.yaml \
--set postgresql.enabled=false \
--set rabbitmq.enabled=false \
--set-string secrets.databaseURL="$DATABASE_URL" \
--set-string secrets.rabbitmqURL="$RABBITMQ_URL"
```
## 7. Upgrade and remove
```bash
make helm-deploy-production
helm -n go-insane-production history go-guess
helm -n go-insane-production rollback go-guess 1
make helm-down-production
```
Uninstalling removes the release secrets, so store `secrets.jwtSecret` and the
two connection URLs outside the cluster before deleting a release whose database
must survive.
## 8. Verify
```bash
kubectl -n go-insane-production get ingress go-guess
kubectl -n go-insane-production get certificate
kubectl -n go-insane-production rollout status deployment/go-guess-api
kubectl -n go-insane-production rollout status deployment/go-guess-web
curl --resolve nmbs.guess.local:443:127.0.0.1 --cacert go-guess-ca.crt \
https://nmbs.guess.local/api/health
```
The test pod installed by `helm test` checks the API health endpoint and the web
root from inside the cluster.

View file

@ -0,0 +1,148 @@
{{- include "go-guess.validate" . -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "go-guess.apiDeploymentName" . }}
labels:
{{- include "go-guess.labels" . | nindent 4 }}
app.kubernetes.io/component: api
spec:
replicas: {{ .Values.api.replicaCount }}
strategy:
type: {{ .Values.api.strategy.type }}
selector:
matchLabels:
{{- include "go-guess.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: api
template:
metadata:
annotations:
checksum/secrets: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
{{- with .Values.api.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "go-guess.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: api
{{- with .Values.api.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
automountServiceAccountToken: false
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
{{- toYaml .Values.api.podSecurityContext | nindent 8 }}
containers:
- name: api
image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}"
imagePullPolicy: {{ .Values.api.image.pullPolicy }}
securityContext:
{{- toYaml .Values.api.securityContext | nindent 12 }}
env:
- name: APP_ENV
value: {{ .Values.api.environment | quote }}
- name: FRONTEND_URL
value: {{ include "go-guess.frontendURL" . | quote }}
- name: TOKEN_TTL
value: {{ .Values.api.tokenTTL | quote }}
- name: MAX_UPLOAD_MB
value: {{ .Values.api.maxUploadMB | quote }}
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "go-guess.secretName" . }}
key: database-url
- name: RABBITMQ_URL
valueFrom:
secretKeyRef:
name: {{ include "go-guess.secretName" . }}
key: rabbitmq-url
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "go-guess.secretName" . }}
key: jwt-secret
{{- if .Values.api.goLoose.enabled }}
- name: GO_LOOSE_AUTH_DOMAIN
value: {{ .Values.api.goLoose.authDomain | quote }}
- name: GO_LOOSE_APP_DOMAIN
value: {{ .Values.domain | quote }}
{{- if .Values.api.goLoose.ca.existingSecret }}
- name: GO_LOOSE_CA_FILE
value: {{ .Values.api.goLoose.ca.mountPath | quote }}
{{- end }}
{{- range $tenant, $credentials := .Values.api.goLoose.tenants }}
{{- $envPrefix := printf "GO_LOOSE_%s" (upper $tenant) }}
{{- $keyPrefix := printf "go-loose-%s" (lower $tenant) }}
{{- if $credentials.clientID }}
- name: {{ $envPrefix }}_CLIENT_ID
valueFrom:
secretKeyRef:
name: {{ include "go-guess.secretName" $ }}
key: {{ $keyPrefix }}-client-id
{{- end }}
{{- if $credentials.clientSecret }}
- name: {{ $envPrefix }}_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "go-guess.secretName" $ }}
key: {{ $keyPrefix }}-client-secret
{{- end }}
{{- end }}
{{- end }}
ports:
- name: http
containerPort: 8080
protocol: TCP
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 12
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 20
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
resources:
{{- toYaml .Values.api.resources | nindent 12 }}
volumeMounts:
- name: temporary-files
mountPath: /tmp
{{- if and .Values.api.goLoose.enabled .Values.api.goLoose.ca.existingSecret }}
- name: go-loose-ca
mountPath: {{ dir .Values.api.goLoose.ca.mountPath }}
readOnly: true
{{- end }}
volumes:
- name: temporary-files
emptyDir: {}
{{- if and .Values.api.goLoose.enabled .Values.api.goLoose.ca.existingSecret }}
- name: go-loose-ca
secret:
secretName: {{ .Values.api.goLoose.ca.existingSecret }}
items:
- key: {{ .Values.api.goLoose.ca.key }}
path: {{ base .Values.api.goLoose.ca.mountPath }}
{{- 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 }}

View file

@ -0,0 +1,18 @@
apiVersion: v1
kind: Service
metadata:
# Kept as the literal name "api": the web image proxies /api/ to api:8080.
name: {{ include "go-guess.apiServiceName" . }}
labels:
{{- include "go-guess.labels" . | nindent 4 }}
app.kubernetes.io/component: api
spec:
type: {{ .Values.api.service.type }}
selector:
{{- include "go-guess.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: api
ports:
- name: http
port: {{ .Values.api.service.port }}
targetPort: http
protocol: TCP

View file

@ -0,0 +1,76 @@
{{- if and .Values.ingress.enabled .Values.ingress.tls.enabled .Values.certManager.enabled }}
{{- $fullname := include "go-guess.fullname" . }}
{{- $caSecret := include "go-guess.certManagerCASecretName" . }}
{{- $tlsSecret := include "go-guess.tlsSecretName" . }}
{{- if not .Values.certManager.issuerRef }}
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: {{ $fullname }}-selfsigned
labels:
{{- include "go-guess.labels" . | nindent 4 }}
app.kubernetes.io/component: certificate
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ $fullname }}-web-ca
labels:
{{- include "go-guess.labels" . | nindent 4 }}
app.kubernetes.io/component: certificate
spec:
secretName: {{ $caSecret }}
commonName: {{ printf "%s-ca" $fullname }}
duration: {{ .Values.certManager.duration }}
renewBefore: {{ .Values.certManager.renewBefore }}
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: {{ $fullname }}-selfsigned
kind: Issuer
group: cert-manager.io
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: {{ $fullname }}-web-ca
labels:
{{- include "go-guess.labels" . | nindent 4 }}
app.kubernetes.io/component: certificate
spec:
ca:
secretName: {{ $caSecret }}
---
{{- end }}
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ $fullname }}-web
labels:
{{- include "go-guess.labels" . | nindent 4 }}
app.kubernetes.io/component: certificate
spec:
secretName: {{ $tlsSecret }}
duration: {{ .Values.certManager.duration }}
renewBefore: {{ .Values.certManager.renewBefore }}
privateKey:
algorithm: ECDSA
size: 256
usages:
- server auth
dnsNames:
{{- range .Values.tenants }}
- {{ printf "%s.%s" . $.Values.domain | quote }}
{{- end }}
issuerRef:
{{- if .Values.certManager.issuerRef }}
{{- include "go-guess.certManagerIssuerRef" . | nindent 4 }}
{{- else }}
name: {{ $fullname }}-web-ca
kind: Issuer
group: cert-manager.io
{{- end }}
{{- end }}

View file

@ -0,0 +1,89 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "go-guess.webDeploymentName" . }}
labels:
{{- include "go-guess.labels" . | nindent 4 }}
app.kubernetes.io/component: web
spec:
replicas: {{ .Values.web.replicaCount }}
strategy:
{{- toYaml .Values.web.strategy | nindent 4 }}
selector:
matchLabels:
{{- include "go-guess.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: web
template:
metadata:
{{- with .Values.web.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "go-guess.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: web
{{- with .Values.web.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
automountServiceAccountToken: false
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
{{- toYaml .Values.web.podSecurityContext | nindent 8 }}
containers:
- name: web
image: "{{ .Values.web.image.repository }}:{{ .Values.web.image.tag }}"
imagePullPolicy: {{ .Values.web.image.pullPolicy }}
securityContext:
{{- toYaml .Values.web.securityContext | nindent 12 }}
ports:
- name: http
containerPort: 80
protocol: TCP
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 3
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 6
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
resources:
{{- toYaml .Values.web.resources | nindent 12 }}
volumeMounts:
- name: nginx-cache
mountPath: /var/cache/nginx
- name: nginx-run
mountPath: /var/run
- name: temporary-files
mountPath: /tmp
volumes:
- name: nginx-cache
emptyDir: {}
- name: nginx-run
emptyDir: {}
- name: temporary-files
emptyDir: {}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View file

@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "go-guess.webDeploymentName" . }}
labels:
{{- include "go-guess.labels" . | nindent 4 }}
app.kubernetes.io/component: web
spec:
type: {{ .Values.web.service.type }}
selector:
{{- include "go-guess.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: web
ports:
- name: http
port: {{ .Values.web.service.port }}
targetPort: http
protocol: TCP