diff --git a/apps/app-grist.yaml b/apps/app-grist.yaml new file mode 100644 index 0000000..1100945 --- /dev/null +++ b/apps/app-grist.yaml @@ -0,0 +1,17 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: grist + namespace: argocd +spec: + project: default + source: + repoURL: git@forgejo.forgejo.svc.cluster.local:basicstack/stack.basicstack.de.git + targetRevision: main + path: apps/grist + destination: + server: https://kubernetes.default.svc + namespace: grist + syncPolicy: + syncOptions: + - CreateNamespace=true diff --git a/apps/grist/README.md b/apps/grist/README.md new file mode 100644 index 0000000..961dd01 --- /dev/null +++ b/apps/grist/README.md @@ -0,0 +1,94 @@ +# Grist Deployment + +This directory contains the Kubernetes manifests for the Grist deployment at https://grist.basicstack.de + +Grist is an open-source, self-hostable spreadsheet / database hybrid ([grist-core](https://github.com/gristlabs/grist-core)). + +## Components + +- **grist-deployment.yaml**: Main Grist application deployment + - Image: `gristlabs/grist:1.7.7` + - Port: 8484 + - OIDC login via Pocket ID (`https://auth.basicstack.de`) + - Data persisted at `/persist` + +- **grist-service.yaml**: ClusterIP service exposing port 80 -> 8484 + +- **grist-data-pvc.yaml**: PVC for Grist app data (`/persist`), 20 GiB on `hcloud-volumes-encrypted` + +- **grist-postgres-deployment.yaml** / **grist-postgres-service.yaml**: In-namespace PostgreSQL 16 for the Grist home database (TypeORM connection) + +- **grist-db-pvc.yaml**: PVC for PostgreSQL data, 20 GiB on `hcloud-volumes-encrypted` + +- **grist-ingress.yaml**: Traefik ingress with TLS + - Host: `grist.basicstack.de` + - TLS via cert-manager (`letsencrypt-prod`, DNS-01 via Hetzner webhook) + +- **grist-db-credentials-sealed.yaml**: SealedSecret for PostgreSQL user password +- **grist-app-secrets-sealed.yaml**: SealedSecret with `GRIST_SESSION_SECRET` +- **grist-oidc-secret-sealed.yaml**: SealedSecret with Pocket ID OIDC client credentials + +## OIDC Configuration (Pocket ID) + +- Pocket ID group: `grist` +- Client name in Pocket ID: `Grist` +- Callback URL: `https://grist.basicstack.de/oauth2/callback` +- Client is restricted to members of the `grist` group + +Grist environment variables driving OIDC (from https://pocket-id.org/docs/client-examples/grist): + +``` +GRIST_OIDC_IDP_ISSUER=https://auth.basicstack.de +GRIST_OIDC_IDP_CLIENT_ID= +GRIST_OIDC_IDP_CLIENT_SECRET= +GRIST_OIDC_SP_HOST=https://grist.basicstack.de +GRIST_OIDC_IDP_SCOPES="openid email profile" +GRIST_OIDC_IDP_END_SESSION_ENDPOINT=https://auth.basicstack.de/api/oidc/end-session +GRIST_OIDC_SP_IGNORE_EMAIL_VERIFIED=true +GRIST_DEFAULT_EMAIL=andreas.leinen@basicstack.de +GRIST_FORCE_LOGIN=true +``` + +`GRIST_DEFAULT_EMAIL` is used by Grist as the primary admin identity - the value must be a valid Pocket ID user. + +## Storage + +Both PVCs (`grist-data`, `grist-db`) use `hcloud-volumes-encrypted` (Retain reclaim policy) as required for +sensitive workload data on Hetzner. + +## GitOps + +Managed by Argo CD via `apps/app-grist.yaml`: + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: grist + namespace: argocd +spec: + project: default + source: + repoURL: git@forgejo.forgejo.svc.cluster.local:basicstack/stack.basicstack.de.git + targetRevision: main + path: apps/grist + destination: + server: https://kubernetes.default.svc + namespace: grist + syncPolicy: + syncOptions: + - CreateNamespace=true +``` + +## Verification + +``` +kubectl -n grist get pods +kubectl -n grist get pvc +kubectl -n grist get ingress +curl -I https://grist.basicstack.de +``` + +## Backup + +The PostgreSQL PVC uses reclaim policy `Retain`. For scheduled logical backups add a `CronJob` like the one in `apps/directus/directus-backup-cronjob.yaml`. diff --git a/apps/grist/grist-app-secrets-sealed.yaml b/apps/grist/grist-app-secrets-sealed.yaml new file mode 100644 index 0000000..112d339 --- /dev/null +++ b/apps/grist/grist-app-secrets-sealed.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + creationTimestamp: null + name: grist-app-secrets + namespace: grist +spec: + encryptedData: + session-secret: AgBN2KDg6QQgvhD2WvTG/r4+y6FrY7EXc8kU8N4hUDIh/VJDoyK+xiEHFvuOXMH3ZpNw4qTEpL+a+6LVWM1/c6mdcTIW5J52+hOOoXEtSXJhoBCjljgddlTTxHy9sIdE0694dMgeBJwmnvT9vm1TNQWMbiBaD05Xh4VCX3D908L4l/CiM11M9CLkoMGK7cTz9OCuGtd36k4OUxmw3/mQnafCDyzKf07NRW70Fr/bMGCdKE4Jt1rqWBfIaTb2mq96FCHosFq37keTCh1gu2nKf3iWesfv40rsVNN3wEhATaG2ghmjP2ul21zMOIO5GuCnMcawZv8VR1gyb5f98WOinL1zf5NMPCPRdTl/3G2gpqPc9VRNxOGguup3NLCaV2J+vbH5dgEj/h6zeTXw5bRARRQHAaGhUgFS3PALzubTzaUyNyFOyaMoXbAnE79HU+7QEd3Yjpdujup4Pe6OioK2k7jGWJj/jxZokffCjUsG9+hvNlg2E4ETtXxmHXIkwgTx7nHON6UApnprCtAUatRuMT3H+4VzfgkjV6y2aIHcp21EbH+qDBLo83+sBeUeWYyRtA/W2BGoAhx1CXeBysmEjD/Cls2h2PVhIvUdfNBY9fDgLemuK7MxdKbR8hRb3zHeedLXM4wevg2zCTyyGtizeC3JmpnqO1UHsKBZMGyUh43r+7sh3SyBKVUJxZt0q2JOWibaZNbIsrqcAyb8Te/QZlayZ2s2BxvMvvNrNpcOTYs/KzwwaHva8OUl + template: + metadata: + creationTimestamp: null + name: grist-app-secrets + namespace: grist diff --git a/apps/grist/grist-data-pvc.yaml b/apps/grist/grist-data-pvc.yaml new file mode 100644 index 0000000..e83280e --- /dev/null +++ b/apps/grist/grist-data-pvc.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: grist-data + namespace: grist + labels: + app: grist + component: data +spec: + accessModes: + - ReadWriteOnce + storageClassName: hcloud-volumes-encrypted + resources: + requests: + storage: 20Gi diff --git a/apps/grist/grist-db-credentials-sealed.yaml b/apps/grist/grist-db-credentials-sealed.yaml new file mode 100644 index 0000000..b426a69 --- /dev/null +++ b/apps/grist/grist-db-credentials-sealed.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + creationTimestamp: null + name: grist-db-credentials + namespace: grist +spec: + encryptedData: + postgres-password: AgA7MFNJ8//EOg4CdfHlSe2yGW6zcJyGsoiWVYlbVkb2GgMZmGQDH/ImYOTfeZXkEQhAWhA0braRB7Prx8fwpON2+6PsOczg+S1N1ykuDMJ+KDuXHhJtm5LNqbgibddX9y6YYInorgxM5mh/ORt2D1IdTwf1AXACkEkp8Ws4IGIfuUCym7kNM77mVD9jW4xyf/c7RE/J3va7L/6SRdx2rdoil8loMzMTHH+lvc738UdFA67hoLkKuseiYgA6NZap5Ppb48GTQul47ji3wH24F/u8DTKiHWbMBxwWO48gQJvAISgXSCW4eiKhkAcm28JACCOd1jT7Asen/yd5qEISB8Fr8q2qwxQwM3kXFIJRf9VVN7BWv6hEMnDO3AhBmcRr+gCPhKial17Varakh0ruBXB4eQDlxGQZ09trwtRPZkOsQnNf38iMQBpDH5U12tASmi1t5ddGKzQQh3EWx1vJyap8xNFf5zK6MuoDEMekHrjcclqgoMWppMH26Daghp2XZl740bV0UhLQBO1TOFEORHBs2DA2A3msmN25selVxjFBHvwLZHQJOHoVHbcqlbF7e2bFSLnij1TBMlPre/llBzoiVYrjHQoHw3fg7xVMrPBT+U1wn4ItDFZTP+nCxnWejtQ/dxzBfMrvBcHVhACsw7VhVkM5nhNFJBda65czyud0BWCSMYJYZEBZjDs220/mhQWIh2/RLANN6pcwJ2ONGyNJs3uo7Q245DlmDTjH5Qppmg== + template: + metadata: + creationTimestamp: null + name: grist-db-credentials + namespace: grist diff --git a/apps/grist/grist-db-pvc.yaml b/apps/grist/grist-db-pvc.yaml new file mode 100644 index 0000000..bf01ca4 --- /dev/null +++ b/apps/grist/grist-db-pvc.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: grist-db + namespace: grist + labels: + app: grist + component: database +spec: + accessModes: + - ReadWriteOnce + storageClassName: hcloud-volumes-encrypted + resources: + requests: + storage: 20Gi diff --git a/apps/grist/grist-deployment.yaml b/apps/grist/grist-deployment.yaml new file mode 100644 index 0000000..f4bcbf0 --- /dev/null +++ b/apps/grist/grist-deployment.yaml @@ -0,0 +1,127 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: grist + namespace: grist + labels: + app: grist + component: app +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: grist + component: app + template: + metadata: + labels: + app: grist + component: app + spec: + initContainers: + - name: wait-for-postgres + image: postgres:16-alpine + command: + - sh + - -c + - | + until pg_isready -h grist-postgres -U grist; do + echo "Waiting for PostgreSQL..." + sleep 2 + done + securityContext: + fsGroup: 1000 + containers: + - name: grist + image: gristlabs/grist:1.7.7 + ports: + - name: http + containerPort: 8484 + env: + - name: APP_HOME_URL + value: https://grist.basicstack.de + - name: GRIST_DOMAIN + value: grist.basicstack.de + - name: GRIST_SINGLE_ORG + value: docs + - name: GRIST_ORG_IN_PATH + value: "false" + - name: GRIST_DEFAULT_PRODUCT + value: Free + - name: GRIST_HIDE_UI_ELEMENTS + value: billing + - name: PORT + value: "8484" + - name: TYPEORM_TYPE + value: postgres + - name: TYPEORM_HOST + value: grist-postgres + - name: TYPEORM_PORT + value: "5432" + - name: TYPEORM_DATABASE + value: grist + - name: TYPEORM_USERNAME + value: grist + - name: TYPEORM_PASSWORD + valueFrom: + secretKeyRef: + name: grist-db-credentials + key: postgres-password + - name: GRIST_SESSION_SECRET + valueFrom: + secretKeyRef: + name: grist-app-secrets + key: session-secret + - name: GRIST_OIDC_IDP_ISSUER + value: https://auth.basicstack.de + - name: GRIST_OIDC_IDP_CLIENT_ID + valueFrom: + secretKeyRef: + name: grist-oidc-secret + key: client-id + - name: GRIST_OIDC_IDP_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: grist-oidc-secret + key: client-secret + - name: GRIST_OIDC_SP_HOST + value: https://grist.basicstack.de + - name: GRIST_OIDC_IDP_SCOPES + value: openid email profile + - name: GRIST_OIDC_IDP_END_SESSION_ENDPOINT + value: https://auth.basicstack.de/api/oidc/end-session + - name: GRIST_OIDC_SP_IGNORE_EMAIL_VERIFIED + value: "true" + - name: GRIST_DEFAULT_EMAIL + value: andreas.leinen@basicstack.de + - name: GRIST_FORCE_LOGIN + value: "true" + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "1" + memory: 2Gi + livenessProbe: + tcpSocket: + port: 8484 + initialDelaySeconds: 60 + periodSeconds: 10 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: /status + port: 8484 + initialDelaySeconds: 30 + periodSeconds: 5 + timeoutSeconds: 3 + volumeMounts: + - name: data + mountPath: /persist + volumes: + - name: data + persistentVolumeClaim: + claimName: grist-data diff --git a/apps/grist/grist-ingress.yaml b/apps/grist/grist-ingress.yaml new file mode 100644 index 0000000..50b991d --- /dev/null +++ b/apps/grist/grist-ingress.yaml @@ -0,0 +1,26 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: grist + namespace: grist + labels: + app: grist + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + ingressClassName: traefik + tls: + - hosts: + - grist.basicstack.de + secretName: grist-tls + rules: + - host: grist.basicstack.de + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: grist + port: + number: 80 diff --git a/apps/grist/grist-oidc-secret-sealed.yaml b/apps/grist/grist-oidc-secret-sealed.yaml new file mode 100644 index 0000000..c7f2b26 --- /dev/null +++ b/apps/grist/grist-oidc-secret-sealed.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + creationTimestamp: null + name: grist-oidc-secret + namespace: grist +spec: + encryptedData: + client-id: AgAAQEIKFFtfc3pm1yp3HKu7uIiTsYtcer6CYJbrUAY6bwNBE+Cj2NF3wvDVeW1M2padhXqyuuhuOIaYbbQ8NOgR3kVg6UIKgn5oNimpXgkwef4xCuGNbNN7kfRfVTJfwrJm8jON0nzmmvxnmsNsz7hJgRszQ2Tp1qJlCI/KIPsIZ0vcXehy+VWV5xBOQOnP+b/wxR+tbt9BnDX/acRi95JAJLXYabqkD7FDc0rLGiWmEsyl4prhhRcT2Ux7O6CJuyegAOOodPDsylkV2iyaI6L3EEY9IrrV5w4ta2tDDPEcnuLkUo/1LipfJUSGzO/tqYJMAbd6klTtfI3cIjimmQXUP/0uiy4YXT7LJud/yEwjSajRIMAdIMQNhAcXPM3EnW85nck4NYSO8qUgu04+lpPiQKiE1eSjCa/LWBIqOMG69nE/hZCCceNBa2krvJIhjrPewBEMxBvg4aCUUS9GO3YanoDN3Y5T9HcpjvbhtLvLMGu20uAKxw63m8DTyNnzruYkL4Qr/5KxyhquCGv9YuwJMtaiUlWc36mh6YKEyyiYSe56cMegUnIZKBWWjsJwJm+ApPKZSI3u9/AGiHodF9tKmqt8vCzuqyzhsAskQXpewdn3iHe2IY2KwuWPPoObUaaSYWcKdoEZYVgaY9IofXf/x4GbJ5GVqbrwCGFpXJGk3GFFtvlyFICINU+CCp5PXgoMiqvpNvUVEHVclCmcTMi8lWufWQi/6Xu4gH+08QHVMkzf55o= + client-secret: AgBrF2e/M48zFC0PdXm08IavK+qa3Sl3a0qmO0+eH/z5WYnRpgos5dFdCLxcrdDcJUgKy//pU+I23qoTXqgybxRnfVzsOdYb4UksGgWh9640BQ/NRuxqhdnfXjQN7PrJj+D+fumwRdTQU1PqyzEePM/6CVf6Ro9vdP6iTMDzbAnPnHoA8L8Hd+f3YKo43OKMR2GMwjNKAhpD20wMCYt1vTiEQX4drR9JW4mjcKfSCdaIrEFwIYn4EBkr1SwuC4Ek/KTn4CNQO82BQH+jjWO5CHRvLP6b4v2bVkgdQEqLR4+HpNjMm0JBKOjHvhfKv952T5mwROamWcpNlN4KW02YzIZMBU+dh7WhouZkDkQCalP4G7AGoLrjhWmLIgS56JqTuoiobFf90Uvz6ewcDmHAzPuYZMfn2mKndPYvoi/KSGzM3ceeNrFxpHCPhsG+UHbOfSCBmg7jqIeE9ial6fMsRRLeEqc3ay4lAN7PBXwJ/o3XMrezUy8RQsXLex9oS0IS9rktimwQNWEpY1adwWRERdBKqGAIEbVofVwai/SuC/dcgQsoX2r9kJOiA8ahM+eDxUg1534hb21v3tFWykp3y/EbFBxbPPtCsUY4ghZZ8O/lLtXsZdHxaq68RKbVqDbRGa/eLqCqStWS8Bk1XzwkJFBBHFcN9hDvNwoT+33QSSsuuiw8gLiztlSrewo33rVKEc9Enqb+f/uUX6HY9j3Iz7hvKZ2aYX/U8ayNN7hQaJryYQ== + template: + metadata: + creationTimestamp: null + name: grist-oidc-secret + namespace: grist diff --git a/apps/grist/grist-postgres-deployment.yaml b/apps/grist/grist-postgres-deployment.yaml new file mode 100644 index 0000000..22e5880 --- /dev/null +++ b/apps/grist/grist-postgres-deployment.yaml @@ -0,0 +1,70 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: grist-postgres + namespace: grist + labels: + app: grist + component: database +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: grist + component: database + template: + metadata: + labels: + app: grist + component: database + spec: + containers: + - name: postgres + image: postgres:16-alpine + ports: + - name: postgres + containerPort: 5432 + env: + - name: POSTGRES_DB + value: grist + - name: POSTGRES_USER + value: grist + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: grist-db-credentials + key: postgres-password + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi + livenessProbe: + exec: + command: + - pg_isready + - -U + - grist + initialDelaySeconds: 30 + periodSeconds: 10 + readinessProbe: + exec: + command: + - pg_isready + - -U + - grist + initialDelaySeconds: 5 + periodSeconds: 5 + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + volumes: + - name: data + persistentVolumeClaim: + claimName: grist-db diff --git a/apps/grist/grist-postgres-service.yaml b/apps/grist/grist-postgres-service.yaml new file mode 100644 index 0000000..53e22d4 --- /dev/null +++ b/apps/grist/grist-postgres-service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: grist-postgres + namespace: grist + labels: + app: grist + component: database +spec: + type: ClusterIP + selector: + app: grist + component: database + ports: + - name: postgres + port: 5432 + targetPort: 5432 diff --git a/apps/grist/grist-service.yaml b/apps/grist/grist-service.yaml new file mode 100644 index 0000000..3cb8be4 --- /dev/null +++ b/apps/grist/grist-service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: grist + namespace: grist + labels: + app: grist + component: app +spec: + type: ClusterIP + selector: + app: grist + component: app + ports: + - name: http + port: 80 + targetPort: 8484