diff --git a/README.md b/README.md index 4de5ed224..527c093c7 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,19 @@ docker run -p 3000:3000 --env-file .env quackback Requires PostgreSQL and a Redis-compatible store. Set `DATABASE_URL` and `REDIS_URL` in `.env`. Migrations run automatically on startup. +### Kubernetes + +```bash +helm install quackback ./deploy/kubernetes/quackback \ + --namespace quackback --create-namespace \ + --set secretKey="$(openssl rand -base64 32)" \ + --set baseUrl=https://feedback.example.com \ + --set ingress.host=feedback.example.com \ + --set ingress.tls.secretName=feedback-tls +``` + +See the [Helm chart guide](deploy/kubernetes/quackback/README.md) for full configuration, including running against an external database and object storage. + ## Contributing See the [Contributing Guide](CONTRIBUTING.md) to get started. diff --git a/deploy/README.md b/deploy/README.md index 8d2fb1419..fec064678 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -7,6 +7,7 @@ This directory contains deployment configurations for Quackback. | Option | For | Infrastructure | | ------------------------------------------ | ---------------------------- | -------------------- | | **[Self-Hosted](./self-hosted/README.md)** | Community & Enterprise users | Docker, Bun, any VPS | +| **[Kubernetes](./kubernetes/quackback/README.md)** | Community & Enterprise users | Kubernetes, Helm | | **[Cloud](./cloud/README.md)** | Quackback team only | Cloudflare Workers | --- @@ -43,6 +44,26 @@ See the [Self-Hosted Guide](./self-hosted/README.md) for complete documentation. --- +## Kubernetes + +Running on a cluster instead of a single host? Use the Helm chart at +[`kubernetes/quackback`](./kubernetes/quackback) — it bundles the same +Postgres/Dragonfly/MinIO stack as `docker-compose.prod.yml`, plus a +`pre-upgrade` migration hook Job (per the guidance in `apps/web/Dockerfile`). + +```bash +helm install quackback ./deploy/kubernetes/quackback \ + --namespace quackback --create-namespace \ + --set secretKey="$(openssl rand -base64 32)" \ + --set baseUrl=https://feedback.example.com \ + --set ingress.host=feedback.example.com \ + --set ingress.tls.secretName=feedback-tls +``` + +See the [Helm Chart Guide](./kubernetes/quackback/README.md) for full configuration. + +--- + ## Quackback Cloud (Internal) The `cloud/` directory contains Cloudflare Workers deployment configuration for Quackback Cloud (app.quackback.io). @@ -65,8 +86,14 @@ deploy/ │ ├── wrangler.production.jsonc # Production environment │ ├── .dev.vars.example # Secrets template │ └── .gitignore # Ignores .dev.vars -└── self-hosted/ # Self-hosting documentation - └── README.md # Self-hosted deployment guide +├── self-hosted/ # Self-hosting documentation +│ └── README.md # Self-hosted deployment guide +└── kubernetes/ + └── quackback/ # Helm chart + ├── README.md # Helm chart guide + ├── Chart.yaml + ├── values.yaml + └── templates/ ``` --- diff --git a/deploy/kubernetes/quackback/.helmignore b/deploy/kubernetes/quackback/.helmignore new file mode 100644 index 000000000..71f8808a5 --- /dev/null +++ b/deploy/kubernetes/quackback/.helmignore @@ -0,0 +1,11 @@ +# Patterns to ignore when building packages. +.DS_Store +.git/ +.gitignore +.bak +*.orig +*~ +.project +.idea/ +*.tmproj +.vscode/ diff --git a/deploy/kubernetes/quackback/Chart.yaml b/deploy/kubernetes/quackback/Chart.yaml new file mode 100644 index 000000000..809dd4565 --- /dev/null +++ b/deploy/kubernetes/quackback/Chart.yaml @@ -0,0 +1,20 @@ +apiVersion: v2 +name: quackback +description: Open-source feedback platform — boards, roadmap, changelog, and an AI-powered inbox. +type: application +version: 0.1.0 +appVersion: "0.13.1" +home: https://quackback.io +icon: https://raw.githubusercontent.com/QuackbackIO/quackback/main/.github/logo.svg +sources: + - https://github.com/QuackbackIO/quackback +keywords: + - feedback + - roadmap + - changelog + - support + - helpdesk +maintainers: + - name: Quackback + url: https://quackback.io + email: support@quackback.io diff --git a/deploy/kubernetes/quackback/README.md b/deploy/kubernetes/quackback/README.md new file mode 100644 index 000000000..ef73beaa3 --- /dev/null +++ b/deploy/kubernetes/quackback/README.md @@ -0,0 +1,162 @@ +# Quackback Helm Chart + +Deploy Quackback on Kubernetes with a single, self-contained chart: the app plus +bundled PostgreSQL, Dragonfly (Redis-compatible queue store), and MinIO +(S3-compatible object storage) — the same stack `docker-compose.prod.yml` +bundles for single-host self-hosting, adapted for a cluster. + +## Table of Contents + +- [Quick Start](#quick-start) +- [Migrations](#migrations) +- [Configuration](#configuration) +- [Bundled vs. External Datastores](#bundled-vs-external-datastores) +- [Ingress](#ingress) +- [Scheduled Jobs](#scheduled-jobs) +- [Upgrading](#upgrading) +- [Uninstalling](#uninstalling) + +## Quick Start + +```bash +helm install quackback ./deploy/kubernetes/quackback \ + --namespace quackback --create-namespace \ + --set secretKey="$(openssl rand -base64 32)" \ + --set baseUrl=https://feedback.example.com \ + --set ingress.host=feedback.example.com \ + --set ingress.tls.secretName=feedback-tls +``` + +This brings up the app plus in-cluster Postgres, Dragonfly, and MinIO — +enough to try Quackback end to end. `ingress.tls.secretName` must name a +TLS secret that already exists in the namespace (e.g. one managed by +cert-manager); the chart doesn't create certificates itself. + +Prefer a values file for anything beyond a quick trial — see +[`values.yaml`](values.yaml) for every field, each documented inline. + +```bash +helm install quackback ./deploy/kubernetes/quackback \ + --namespace quackback --create-namespace \ + -f my-values.yaml +``` + +## Migrations + +`apps/web/Dockerfile` documents the intended Kubernetes pattern: skip the +container's built-in startup migration and run migrations from a Helm hook +Job instead, so a rolling deploy doesn't pay a DB round-trip on every cold +start. This chart implements that directly: + +- `templates/migration-job.yaml` runs `bun /app/migrate.mjs` as a + `pre-install,pre-upgrade` hook — it must succeed before the release + proceeds. +- The app Deployment gets `SKIP_MIGRATIONS=true` automatically whenever + `migrations.enabled` is `true` (the default), so it never re-runs them. + +Set `migrations.enabled: false` only if you're managing migrations through +some other out-of-band process; in that case the app's `SKIP_MIGRATIONS` is +left unset and it falls back to running migrations on container start (the +same behavior as `docker run`). + +## Configuration + +The chart covers the same environment variables as +[`.env.prod.example`](../../../.env.prod.example) — see that file for the +full, authoritative list (including every integration: Slack, Linear, Jira, +Discord, etc.). Commonly-set fields: + +| Value | Purpose | +| --------------------------- | ----------------------------------------------------------------- | +| `secretKey` | Required. Session signing/encryption key, 32+ chars. | +| `baseUrl` | Required. Public URL — drives auth, emails, OAuth callbacks. | +| `email.smtp.*` / `email.resendApiKey` | Outbound email. Leave unset to log OTP/invite codes to the pod's console. | +| `oauth.github.*` / `oauth.google.*` | OAuth sign-in providers. | +| `ai.openaiApiKey` / `ai.openaiBaseUrl` / `ai.chatModel` / `ai.embeddingModel` | AI features (summaries, duplicate detection, extraction) — all four must be set together. | +| `app.extraEnv` / `app.extraEnvFrom` | Escape hatch for anything else in `.env.prod.example` (integrations, `EMAIL_INBOUND_*`, per-feature `AI_*_MODEL` overrides). | + +Values not set fall back to the same defaults the app itself uses (see +`docker-entrypoint.sh` and the app's env validation) — nothing in this chart +invents new defaults beyond what self-hosting already documents. + +## Bundled vs. External Datastores + +Each datastore defaults to **enabled** (bundled, in-cluster) so the chart is +usable standalone. For production, point at managed services instead: + +| Datastore | Disable with | Then set | +| ---------- | ------------------------ | ------------------------ | +| PostgreSQL | `postgres.enabled=false` | `externalDatabaseUrl` | +| Dragonfly | `dragonfly.enabled=false`| `externalRedisUrl` | +| MinIO | `minio.enabled=false` | your own `S3_*` vars via `app.extraEnv` | + +The bundled PostgreSQL image (`pgvector/pgvector:pg17`) has `pgvector` but +not `pg_cron` — it's meant for evaluation, not production. If you rely on +the scheduled jobs `pg_cron` normally drives (see +[Scheduled Jobs](#scheduled-jobs) below and the self-hosting docs), use an +external Postgres built from [`docker/postgres/Dockerfile`](../../../docker/postgres/Dockerfile) +(which enables both extensions), or handle the SLA tick via this chart's +`slaTickCronJob` instead. + +Generated passwords (bundled Postgres/MinIO, when left blank) are stable +across `helm upgrade` — the chart reads back the existing Secret instead of +re-rolling a new one, so upgrades never desync from data already written to +the PVC. + +## Ingress + +`ingress.className: nginx` (the default) also adds a few +`nginx.ingress.kubernetes.io/*` annotations (body size, redirect, timeouts) +tuned for Quackback's upload endpoints. Any other `ingress.className` +(`traefik`, `caddy`, ...) — both mentioned as supported reverse proxies in +the [self-hosting guide](../self-hosted/README.md#reverse-proxy) — skips +those and applies only `ingress.annotations` verbatim, so you can add +controller-specific annotations (e.g. `cert-manager.io/cluster-issuer`) +without fighting nginx-specific ones. + +Set `ingress.enabled: false` to skip the Ingress entirely and expose the +`ClusterIP` Service through your own means (a Gateway API `HTTPRoute`, a +different ingress mechanism, `kubectl port-forward`, ...). + +## Scheduled Jobs + +The self-hosting guide's [pg_cron section](../self-hosted/README.md#scheduled-jobs-pg_cron) +recommends two jobs: an SLA escalation tick (`POST /api/v1/internal/sla-tick` +every minute) and a webhook-delivery audit purge. This chart adds an +optional CronJob for the first one — the one with an HTTP endpoint to call: + +```yaml +internalTaskSecret: "" +slaTickCronJob: + enabled: true +``` + +The webhook-delivery purge is a raw SQL statement, not an HTTP endpoint — +run it via `pg_cron` on whichever Postgres you're using (bundled or +external), per the self-hosting guide. + +## Upgrading + +```bash +helm upgrade quackback ./deploy/kubernetes/quackback -f my-values.yaml +``` + +The `pre-upgrade` migration hook runs and must succeed before the app's +Deployment is touched. Back up your database first if you're using the +bundled PostgreSQL — this chart doesn't automate backups (see +[Database Backups](../self-hosted/README.md#database-backups) for the +`pg_dump`/`pg_restore` commands; run them via `kubectl exec` into the +`*-postgres-0` pod). + +## Uninstalling + +```bash +helm uninstall quackback -n quackback +``` + +PersistentVolumeClaims (Postgres data, Dragonfly data, MinIO data) are not +deleted automatically — remove them explicitly if you want a clean slate: + +```bash +kubectl -n quackback delete pvc -l app.kubernetes.io/instance=quackback +``` diff --git a/deploy/kubernetes/quackback/templates/NOTES.txt b/deploy/kubernetes/quackback/templates/NOTES.txt new file mode 100644 index 000000000..ac559002b --- /dev/null +++ b/deploy/kubernetes/quackback/templates/NOTES.txt @@ -0,0 +1,32 @@ +Quackback has been deployed. + +{{- if .Values.ingress.enabled }} + +Once the ingress controller has provisioned, your instance will be available at: + {{ if .Values.ingress.tls.enabled }}https{{ else }}http{{ end }}://{{ .Values.ingress.host }} +{{- else }} + +No ingress was created (ingress.enabled=false). Reach the app with: + kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "quackback.fullname" . }} 3000:{{ .Values.app.port }} +then open http://localhost:3000 +{{- end }} + +{{- if .Values.migrations.enabled }} + +Migrations run automatically via a pre-install/pre-upgrade hook Job +({{ include "quackback.fullname" . }}-migrate). Check its status with: + kubectl -n {{ .Release.Namespace }} logs job/{{ include "quackback.fullname" . }}-migrate +{{- end }} + +{{- if .Values.postgres.enabled }} + +A bundled PostgreSQL StatefulSet is running for evaluation. For production, +point at a managed database instead: set postgres.enabled=false and +externalDatabaseUrl, then see the note on pg_cron in +deploy/kubernetes/README.md. +{{- end }} + +Check the rollout with: + kubectl -n {{ .Release.Namespace }} rollout status deployment/{{ include "quackback.fullname" . }} + +Docs: https://quackback.io/docs/self-hosting diff --git a/deploy/kubernetes/quackback/templates/_helpers.tpl b/deploy/kubernetes/quackback/templates/_helpers.tpl new file mode 100644 index 000000000..eec7a9fb7 --- /dev/null +++ b/deploy/kubernetes/quackback/templates/_helpers.tpl @@ -0,0 +1,97 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "quackback.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "quackback.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "quackback.labels" -}} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +app.kubernetes.io/version: {{ .Values.app.image.tag | default .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{ include "quackback.selectorLabels" . }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "quackback.selectorLabels" -}} +app.kubernetes.io/name: {{ include "quackback.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Image tag, defaulting to the chart's appVersion (kept in sync with the +project's latest tested release — see Chart.yaml). +*/}} +{{- define "quackback.appImage" -}} +{{- printf "%s:%s" .Values.app.image.repository (.Values.app.image.tag | default .Chart.AppVersion) -}} +{{- end }} + +{{/* +Redis/Dragonfly internal URL. Only meaningful when dragonfly.enabled — the +bundled Dragonfly runs with no auth, same as docker-compose.prod.yml. +*/}} +{{- define "quackback.redisUrl" -}} +redis://{{ include "quackback.fullname" . }}-dragonfly:6379 +{{- end }} + +{{/* +MinIO internal endpoint. +*/}} +{{- define "quackback.minioEndpoint" -}} +http://{{ include "quackback.fullname" . }}-minio:9000 +{{- end }} + +{{/* +Stable Postgres password: reused across `helm upgrade` by reading it back +from the already-deployed Secret (lookup), so the bundled StatefulSet's PVC +never ends up holding a password older than the current Secret. Falls back +to postgres.password, then to a random 24-char value on first install. +*/}} +{{- define "quackback.postgresPassword" -}} +{{- $secretName := printf "%s-postgres" (include "quackback.fullname" .) -}} +{{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName -}} +{{- if $existing }} +{{- index $existing.data "POSTGRES_PASSWORD" | b64dec -}} +{{- else if .Values.postgres.password }} +{{- .Values.postgres.password -}} +{{- else -}} +{{- randAlphaNum 24 -}} +{{- end -}} +{{- end }} + +{{/* +Stable MinIO root password — same reuse-on-upgrade rationale as +quackback.postgresPassword above. +*/}} +{{- define "quackback.minioPassword" -}} +{{- $secretName := printf "%s-minio" (include "quackback.fullname" .) -}} +{{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName -}} +{{- if $existing }} +{{- index $existing.data "MINIO_ROOT_PASSWORD" | b64dec -}} +{{- else if .Values.minio.rootPassword }} +{{- .Values.minio.rootPassword -}} +{{- else -}} +{{- randAlphaNum 24 -}} +{{- end -}} +{{- end }} diff --git a/deploy/kubernetes/quackback/templates/configmap.yaml b/deploy/kubernetes/quackback/templates/configmap.yaml new file mode 100644 index 000000000..73f091863 --- /dev/null +++ b/deploy/kubernetes/quackback/templates/configmap.yaml @@ -0,0 +1,51 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "quackback.fullname" . }}-config + labels: + {{- include "quackback.labels" . | nindent 4 }} +data: + BASE_URL: {{ required "baseUrl is required" .Values.baseUrl | quote }} + PORT: {{ .Values.app.port | quote }} + DISABLE_TELEMETRY: {{ .Values.disableTelemetry | quote }} + SEED_DATABASE: {{ .Values.seedDatabase | quote }} + {{- if .Values.migrations.enabled }} + # Migrations run in the pre-install/pre-upgrade hook Job instead — see + # templates/migration-job.yaml. + SKIP_MIGRATIONS: "true" + {{- end }} + {{- if .Values.dragonfly.enabled }} + REDIS_URL: {{ include "quackback.redisUrl" . | quote }} + {{- end }} + {{- if .Values.email.from }} + EMAIL_FROM: {{ .Values.email.from | quote }} + {{- end }} + {{- if .Values.email.smtp.host }} + EMAIL_SMTP_HOST: {{ .Values.email.smtp.host | quote }} + EMAIL_SMTP_PORT: {{ .Values.email.smtp.port | quote }} + EMAIL_SMTP_USER: {{ .Values.email.smtp.user | quote }} + {{- if .Values.email.smtp.secure }} + EMAIL_SMTP_SECURE: {{ .Values.email.smtp.secure | quote }} + {{- end }} + {{- end }} + {{- if .Values.ai.openaiBaseUrl }} + OPENAI_BASE_URL: {{ .Values.ai.openaiBaseUrl | quote }} + {{- end }} + {{- if .Values.ai.chatModel }} + AI_CHAT_MODEL: {{ .Values.ai.chatModel | quote }} + {{- end }} + {{- if .Values.ai.embeddingModel }} + AI_EMBEDDING_MODEL: {{ .Values.ai.embeddingModel | quote }} + {{- end }} + {{- if .Values.minio.enabled }} + S3_ENDPOINT: {{ include "quackback.minioEndpoint" . | quote }} + S3_BUCKET: {{ .Values.minio.bucketName | quote }} + S3_REGION: "us-east-1" + S3_FORCE_PATH_STYLE: "true" + # The bundled MinIO is an in-cluster, HTTP-only ClusterIP service the + # browser can't reach directly. Proxy all object I/O through the app + # (BASE_URL/api/storage, HTTPS) so presigned URLs never expose the internal + # http://...-minio:9000 endpoint (which would otherwise trip Mixed Content + # blocks on an HTTPS BASE_URL). + S3_PROXY: "true" + {{- end }} diff --git a/deploy/kubernetes/quackback/templates/deployment.yaml b/deploy/kubernetes/quackback/templates/deployment.yaml new file mode 100644 index 000000000..d8d7810c4 --- /dev/null +++ b/deploy/kubernetes/quackback/templates/deployment.yaml @@ -0,0 +1,152 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "quackback.fullname" . }} + labels: + {{- include "quackback.labels" . | nindent 4 }} + app.kubernetes.io/component: app +spec: + {{- if not .Values.app.autoscaling.enabled }} + replicas: {{ .Values.app.replicaCount }} + {{- end }} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + {{- include "quackback.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: app + template: + metadata: + labels: + {{- include "quackback.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: app + {{- with .Values.app.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.app.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.app.podSecurityContext | nindent 8 }} + {{- with .Values.app.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.app.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.app.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + initContainers: + {{- if .Values.postgres.enabled }} + - name: wait-for-postgres + image: busybox:1.37 + command: + - sh + - -c + - | + until nc -z {{ include "quackback.fullname" . }}-postgres 5432; do + echo "Waiting for PostgreSQL..." + sleep 2 + done + echo "PostgreSQL is ready." + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 50m + memory: 32Mi + {{- end }} + {{- if .Values.dragonfly.enabled }} + - name: wait-for-dragonfly + image: busybox:1.37 + command: + - sh + - -c + - | + until nc -z {{ include "quackback.fullname" . }}-dragonfly 6379; do + echo "Waiting for Dragonfly..." + sleep 2 + done + echo "Dragonfly is ready." + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 50m + memory: 32Mi + {{- end }} + containers: + - name: quackback + image: "{{ include "quackback.appImage" . }}" + imagePullPolicy: {{ .Values.app.image.pullPolicy }} + securityContext: + {{- toYaml .Values.app.securityContext | nindent 12 }} + ports: + - name: http + containerPort: {{ .Values.app.port }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "quackback.fullname" . }}-config + - secretRef: + name: {{ include "quackback.fullname" . }}-secret + {{- with .Values.app.extraEnvFrom }} + {{- toYaml . | nindent 12 }} + {{- end }} + env: + {{- if .Values.postgres.enabled }} + - name: DB_USER + value: {{ .Values.postgres.user | quote }} + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "quackback.fullname" . }}-postgres + key: POSTGRES_PASSWORD + - name: DATABASE_URL + value: postgresql://$(DB_USER):$(DB_PASSWORD)@{{ include "quackback.fullname" . }}-postgres:5432/{{ .Values.postgres.database }} + {{- end }} + {{- if .Values.minio.enabled }} + - name: S3_ACCESS_KEY_ID + value: {{ .Values.minio.rootUser | quote }} + - name: S3_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ include "quackback.fullname" . }}-minio + key: MINIO_ROOT_PASSWORD + {{- end }} + {{- with .Values.app.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + livenessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + {{- toYaml .Values.app.resources | nindent 12 }} diff --git a/deploy/kubernetes/quackback/templates/dragonfly-service.yaml b/deploy/kubernetes/quackback/templates/dragonfly-service.yaml new file mode 100644 index 000000000..61ee5dc16 --- /dev/null +++ b/deploy/kubernetes/quackback/templates/dragonfly-service.yaml @@ -0,0 +1,20 @@ +{{- if .Values.dragonfly.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "quackback.fullname" . }}-dragonfly + labels: + {{- include "quackback.labels" . | nindent 4 }} + app.kubernetes.io/component: dragonfly +spec: + type: ClusterIP + clusterIP: None + ports: + - port: 6379 + targetPort: redis + protocol: TCP + name: redis + selector: + {{- include "quackback.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: dragonfly +{{- end }} diff --git a/deploy/kubernetes/quackback/templates/dragonfly-statefulset.yaml b/deploy/kubernetes/quackback/templates/dragonfly-statefulset.yaml new file mode 100644 index 000000000..3594604ff --- /dev/null +++ b/deploy/kubernetes/quackback/templates/dragonfly-statefulset.yaml @@ -0,0 +1,80 @@ +{{- if .Values.dragonfly.enabled }} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "quackback.fullname" . }}-dragonfly + labels: + {{- include "quackback.labels" . | nindent 4 }} + app.kubernetes.io/component: dragonfly +spec: + serviceName: {{ include "quackback.fullname" . }}-dragonfly + replicas: 1 + selector: + matchLabels: + {{- include "quackback.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: dragonfly + template: + metadata: + labels: + {{- include "quackback.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: dragonfly + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: dragonfly + image: "{{ .Values.dragonfly.image.repository }}:{{ .Values.dragonfly.image.tag }}" + imagePullPolicy: {{ .Values.dragonfly.image.pullPolicy }} + # cluster_mode=emulated + lock_on_hashtags is required for BullMQ's + # Lua script compatibility — see + # https://www.dragonflydb.io/docs/integrations/bullmq + args: + - dragonfly + - --cluster_mode=emulated + - --lock_on_hashtags + - --maxmemory={{ .Values.dragonfly.maxmemory }} + ports: + - name: redis + containerPort: 6379 + protocol: TCP + livenessProbe: + tcpSocket: + port: redis + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + tcpSocket: + port: redis + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + resources: + {{- toYaml .Values.dragonfly.resources | nindent 12 }} + volumeMounts: + - name: dragonfly-data + mountPath: /data + volumeClaimTemplates: + - metadata: + name: dragonfly-data + spec: + accessModes: ["ReadWriteOnce"] + {{- with .Values.dragonfly.storage.storageClassName }} + storageClassName: {{ . }} + {{- end }} + resources: + requests: + storage: {{ .Values.dragonfly.storage.size }} +{{- end }} diff --git a/deploy/kubernetes/quackback/templates/hpa.yaml b/deploy/kubernetes/quackback/templates/hpa.yaml new file mode 100644 index 000000000..d4f96252c --- /dev/null +++ b/deploy/kubernetes/quackback/templates/hpa.yaml @@ -0,0 +1,23 @@ +{{- if .Values.app.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "quackback.fullname" . }} + labels: + {{- include "quackback.labels" . | nindent 4 }} + app.kubernetes.io/component: app +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "quackback.fullname" . }} + minReplicas: {{ .Values.app.autoscaling.minReplicas }} + maxReplicas: {{ .Values.app.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.app.autoscaling.targetCPUUtilizationPercentage }} +{{- end }} diff --git a/deploy/kubernetes/quackback/templates/ingress.yaml b/deploy/kubernetes/quackback/templates/ingress.yaml new file mode 100644 index 000000000..623d40036 --- /dev/null +++ b/deploy/kubernetes/quackback/templates/ingress.yaml @@ -0,0 +1,38 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "quackback.fullname" . }} + labels: + {{- include "quackback.labels" . | nindent 4 }} + annotations: + {{- if eq .Values.ingress.className "nginx" }} + nginx.ingress.kubernetes.io/ssl-redirect: "true" + nginx.ingress.kubernetes.io/proxy-body-size: "50m" + nginx.ingress.kubernetes.io/proxy-connect-timeout: "60" + nginx.ingress.kubernetes.io/proxy-send-timeout: "60" + nginx.ingress.kubernetes.io/proxy-read-timeout: "60" + {{- end }} + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + ingressClassName: {{ .Values.ingress.className | quote }} + {{- if .Values.ingress.tls.enabled }} + tls: + - hosts: + - {{ required "ingress.host is required when ingress.enabled" .Values.ingress.host | quote }} + secretName: {{ required "ingress.tls.secretName is required when ingress.tls.enabled" .Values.ingress.tls.secretName | quote }} + {{- end }} + rules: + - host: {{ required "ingress.host is required when ingress.enabled" .Values.ingress.host | quote }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "quackback.fullname" . }} + port: + number: {{ .Values.app.port }} +{{- end }} diff --git a/deploy/kubernetes/quackback/templates/migration-job.yaml b/deploy/kubernetes/quackback/templates/migration-job.yaml new file mode 100644 index 000000000..1adb3261d --- /dev/null +++ b/deploy/kubernetes/quackback/templates/migration-job.yaml @@ -0,0 +1,92 @@ +{{/* +apps/web/Dockerfile documents this exact pattern: "K8s deployments should set +SKIP_MIGRATIONS=true and run migrations from a pre-upgrade Helm hook Job +instead — that way cold starts don't wait on a DB round-trip." This Job is +that hook: it runs once before the app Deployment is created/updated and +blocks the release until migrations succeed. +*/}} +{{- if .Values.migrations.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "quackback.fullname" . }}-migrate + labels: + {{- include "quackback.labels" . | nindent 4 }} + app.kubernetes.io/component: migrate + annotations: + helm.sh/hook: pre-install,pre-upgrade + helm.sh/hook-weight: "-5" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + backoffLimit: {{ .Values.migrations.backoffLimit }} + activeDeadlineSeconds: {{ .Values.migrations.activeDeadlineSeconds }} + template: + metadata: + labels: + {{- include "quackback.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: migrate + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + restartPolicy: Never + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if .Values.postgres.enabled }} + initContainers: + - name: wait-for-postgres + image: busybox:1.37 + command: + - sh + - -c + - | + until nc -z {{ include "quackback.fullname" . }}-postgres 5432; do + echo "Waiting for PostgreSQL..." + sleep 2 + done + echo "PostgreSQL is ready." + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 50m + memory: 32Mi + {{- end }} + containers: + - name: migrate + image: "{{ include "quackback.appImage" . }}" + imagePullPolicy: {{ .Values.app.image.pullPolicy }} + command: ["bun", "/app/migrate.mjs"] + securityContext: + {{- toYaml .Values.app.securityContext | nindent 12 }} + env: + {{- if .Values.postgres.enabled }} + - name: DB_USER + value: {{ .Values.postgres.user | quote }} + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "quackback.fullname" . }}-postgres + key: POSTGRES_PASSWORD + - name: DATABASE_URL + value: postgresql://$(DB_USER):$(DB_PASSWORD)@{{ include "quackback.fullname" . }}-postgres:5432/{{ .Values.postgres.database }} + {{- else }} + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ include "quackback.fullname" . }}-secret + key: DATABASE_URL + {{- end }} + resources: + {{- toYaml .Values.migrations.resources | nindent 12 }} + securityContext: + {{- toYaml .Values.app.podSecurityContext | nindent 8 }} +{{- end }} diff --git a/deploy/kubernetes/quackback/templates/minio-init-job.yaml b/deploy/kubernetes/quackback/templates/minio-init-job.yaml new file mode 100644 index 000000000..2d565bf24 --- /dev/null +++ b/deploy/kubernetes/quackback/templates/minio-init-job.yaml @@ -0,0 +1,70 @@ +{{- if .Values.minio.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "quackback.fullname" . }}-minio-init + labels: + {{- include "quackback.labels" . | nindent 4 }} + app.kubernetes.io/component: minio-init + annotations: + helm.sh/hook: post-install,post-upgrade + helm.sh/hook-weight: "0" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + backoffLimit: 5 + template: + metadata: + labels: + {{- include "quackback.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: minio-init + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + restartPolicy: OnFailure + initContainers: + - name: wait-for-minio + image: busybox:1.37 + command: + - sh + - -c + - | + until nc -z {{ include "quackback.fullname" . }}-minio 9000; do + echo "Waiting for MinIO..." + sleep 2 + done + echo "MinIO is ready." + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 50m + memory: 32Mi + containers: + - name: create-bucket + image: minio/mc:latest + env: + - name: MINIO_ROOT_USER + value: {{ .Values.minio.rootUser | quote }} + - name: MINIO_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "quackback.fullname" . }}-minio + key: MINIO_ROOT_PASSWORD + command: + - sh + - -c + - | + mc alias set quackback {{ include "quackback.minioEndpoint" . }} "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" + mc mb --ignore-existing quackback/{{ .Values.minio.bucketName }} + echo "Bucket '{{ .Values.minio.bucketName }}' is ready." + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + cpu: 100m + memory: 64Mi +{{- end }} diff --git a/deploy/kubernetes/quackback/templates/minio-secret.yaml b/deploy/kubernetes/quackback/templates/minio-secret.yaml new file mode 100644 index 000000000..bce076739 --- /dev/null +++ b/deploy/kubernetes/quackback/templates/minio-secret.yaml @@ -0,0 +1,12 @@ +{{- if .Values.minio.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "quackback.fullname" . }}-minio + labels: + {{- include "quackback.labels" . | nindent 4 }} + app.kubernetes.io/component: minio +type: Opaque +stringData: + MINIO_ROOT_PASSWORD: {{ include "quackback.minioPassword" . | quote }} +{{- end }} diff --git a/deploy/kubernetes/quackback/templates/minio-service.yaml b/deploy/kubernetes/quackback/templates/minio-service.yaml new file mode 100644 index 000000000..33f726a5a --- /dev/null +++ b/deploy/kubernetes/quackback/templates/minio-service.yaml @@ -0,0 +1,24 @@ +{{- if .Values.minio.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "quackback.fullname" . }}-minio + labels: + {{- include "quackback.labels" . | nindent 4 }} + app.kubernetes.io/component: minio +spec: + type: ClusterIP + clusterIP: None + ports: + - port: 9000 + targetPort: api + protocol: TCP + name: api + - port: 9001 + targetPort: console + protocol: TCP + name: console + selector: + {{- include "quackback.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: minio +{{- end }} diff --git a/deploy/kubernetes/quackback/templates/minio-statefulset.yaml b/deploy/kubernetes/quackback/templates/minio-statefulset.yaml new file mode 100644 index 000000000..ba3441574 --- /dev/null +++ b/deploy/kubernetes/quackback/templates/minio-statefulset.yaml @@ -0,0 +1,90 @@ +{{- if .Values.minio.enabled }} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "quackback.fullname" . }}-minio + labels: + {{- include "quackback.labels" . | nindent 4 }} + app.kubernetes.io/component: minio +spec: + serviceName: {{ include "quackback.fullname" . }}-minio + replicas: 1 + selector: + matchLabels: + {{- include "quackback.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: minio + template: + metadata: + labels: + {{- include "quackback.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: minio + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: minio + image: "{{ .Values.minio.image.repository }}:{{ .Values.minio.image.tag }}" + imagePullPolicy: {{ .Values.minio.image.pullPolicy }} + args: + - server + - /data + - --console-address + - ":9001" + ports: + - name: api + containerPort: 9000 + protocol: TCP + - name: console + containerPort: 9001 + protocol: TCP + env: + - name: MINIO_ROOT_USER + value: {{ .Values.minio.rootUser | quote }} + - name: MINIO_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "quackback.fullname" . }}-minio + key: MINIO_ROOT_PASSWORD + livenessProbe: + httpGet: + path: /minio/health/live + port: api + initialDelaySeconds: 15 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /minio/health/ready + port: api + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + {{- toYaml .Values.minio.resources | nindent 12 }} + volumeMounts: + - name: minio-data + mountPath: /data + volumeClaimTemplates: + - metadata: + name: minio-data + spec: + accessModes: ["ReadWriteOnce"] + {{- with .Values.minio.storage.storageClassName }} + storageClassName: {{ . }} + {{- end }} + resources: + requests: + storage: {{ .Values.minio.storage.size }} +{{- end }} diff --git a/deploy/kubernetes/quackback/templates/pdb.yaml b/deploy/kubernetes/quackback/templates/pdb.yaml new file mode 100644 index 000000000..07d0a7f9d --- /dev/null +++ b/deploy/kubernetes/quackback/templates/pdb.yaml @@ -0,0 +1,15 @@ +{{- if .Values.app.podDisruptionBudget.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "quackback.fullname" . }} + labels: + {{- include "quackback.labels" . | nindent 4 }} + app.kubernetes.io/component: app +spec: + minAvailable: {{ .Values.app.podDisruptionBudget.minAvailable }} + selector: + matchLabels: + {{- include "quackback.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: app +{{- end }} diff --git a/deploy/kubernetes/quackback/templates/postgres-init-configmap.yaml b/deploy/kubernetes/quackback/templates/postgres-init-configmap.yaml new file mode 100644 index 000000000..a14bdcc70 --- /dev/null +++ b/deploy/kubernetes/quackback/templates/postgres-init-configmap.yaml @@ -0,0 +1,19 @@ +{{- if .Values.postgres.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "quackback.fullname" . }}-postgres-init + labels: + {{- include "quackback.labels" . | nindent 4 }} + app.kubernetes.io/component: postgres +data: + 01-create-extensions.sql: | + -- pgvector is pre-installed in the pgvector/pgvector image. + CREATE EXTENSION IF NOT EXISTS vector; + -- pg_cron needs shared_preload_libraries, which the stock pgvector image + -- doesn't set — the bundled Postgres here is meant for evaluation, not + -- production. For the scheduled jobs pg_cron normally drives (SLA + -- escalation, webhook-delivery purge), either point postgres.enabled=false + -- at a Postgres built from docker/postgres/Dockerfile (which enables + -- pg_cron), or use slaTickCronJob.enabled below for the SLA tick. +{{- end }} diff --git a/deploy/kubernetes/quackback/templates/postgres-secret.yaml b/deploy/kubernetes/quackback/templates/postgres-secret.yaml new file mode 100644 index 000000000..579af8ec3 --- /dev/null +++ b/deploy/kubernetes/quackback/templates/postgres-secret.yaml @@ -0,0 +1,12 @@ +{{- if .Values.postgres.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "quackback.fullname" . }}-postgres + labels: + {{- include "quackback.labels" . | nindent 4 }} + app.kubernetes.io/component: postgres +type: Opaque +stringData: + POSTGRES_PASSWORD: {{ include "quackback.postgresPassword" . | quote }} +{{- end }} diff --git a/deploy/kubernetes/quackback/templates/postgres-service.yaml b/deploy/kubernetes/quackback/templates/postgres-service.yaml new file mode 100644 index 000000000..b5966d1c8 --- /dev/null +++ b/deploy/kubernetes/quackback/templates/postgres-service.yaml @@ -0,0 +1,20 @@ +{{- if .Values.postgres.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "quackback.fullname" . }}-postgres + labels: + {{- include "quackback.labels" . | nindent 4 }} + app.kubernetes.io/component: postgres +spec: + type: ClusterIP + clusterIP: None + ports: + - port: 5432 + targetPort: postgres + protocol: TCP + name: postgres + selector: + {{- include "quackback.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: postgres +{{- end }} diff --git a/deploy/kubernetes/quackback/templates/postgres-statefulset.yaml b/deploy/kubernetes/quackback/templates/postgres-statefulset.yaml new file mode 100644 index 000000000..e3a21e627 --- /dev/null +++ b/deploy/kubernetes/quackback/templates/postgres-statefulset.yaml @@ -0,0 +1,100 @@ +{{- if .Values.postgres.enabled }} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "quackback.fullname" . }}-postgres + labels: + {{- include "quackback.labels" . | nindent 4 }} + app.kubernetes.io/component: postgres +spec: + serviceName: {{ include "quackback.fullname" . }}-postgres + replicas: 1 + selector: + matchLabels: + {{- include "quackback.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: postgres + template: + metadata: + labels: + {{- include "quackback.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: postgres + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: postgres + image: "{{ .Values.postgres.image.repository }}:{{ .Values.postgres.image.tag }}" + imagePullPolicy: {{ .Values.postgres.image.pullPolicy }} + args: + - postgres + - -c + - max_connections=200 + ports: + - name: postgres + containerPort: 5432 + protocol: TCP + env: + - name: POSTGRES_USER + value: {{ .Values.postgres.user | quote }} + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "quackback.fullname" . }}-postgres + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + value: {{ .Values.postgres.database | quote }} + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + livenessProbe: + exec: + command: + - pg_isready + - -U + - {{ .Values.postgres.user }} + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + readinessProbe: + exec: + command: + - pg_isready + - -U + - {{ .Values.postgres.user }} + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + resources: + {{- toYaml .Values.postgres.resources | nindent 12 }} + volumeMounts: + - name: postgres-data + mountPath: /var/lib/postgresql/data + - name: init-extensions + mountPath: /docker-entrypoint-initdb.d + volumes: + - name: init-extensions + configMap: + name: {{ include "quackback.fullname" . }}-postgres-init + volumeClaimTemplates: + - metadata: + name: postgres-data + spec: + accessModes: ["ReadWriteOnce"] + {{- with .Values.postgres.storage.storageClassName }} + storageClassName: {{ . }} + {{- end }} + resources: + requests: + storage: {{ .Values.postgres.storage.size }} +{{- end }} diff --git a/deploy/kubernetes/quackback/templates/secret.yaml b/deploy/kubernetes/quackback/templates/secret.yaml new file mode 100644 index 000000000..92f8834e8 --- /dev/null +++ b/deploy/kubernetes/quackback/templates/secret.yaml @@ -0,0 +1,35 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "quackback.fullname" . }}-secret + labels: + {{- include "quackback.labels" . | nindent 4 }} +type: Opaque +stringData: + SECRET_KEY: {{ required "secretKey is required — generate one with: openssl rand -base64 32" .Values.secretKey | quote }} + {{- if not .Values.postgres.enabled }} + DATABASE_URL: {{ required "externalDatabaseUrl is required when postgres.enabled=false" .Values.externalDatabaseUrl | quote }} + {{- end }} + {{- if not .Values.dragonfly.enabled }} + REDIS_URL: {{ required "externalRedisUrl is required when dragonfly.enabled=false" .Values.externalRedisUrl | quote }} + {{- end }} + {{- if .Values.internalTaskSecret }} + INTERNAL_TASK_SECRET: {{ .Values.internalTaskSecret | quote }} + {{- end }} + {{- if .Values.email.smtp.pass }} + EMAIL_SMTP_PASS: {{ .Values.email.smtp.pass | quote }} + {{- end }} + {{- if .Values.email.resendApiKey }} + EMAIL_RESEND_API_KEY: {{ .Values.email.resendApiKey | quote }} + {{- end }} + {{- if .Values.oauth.github.clientId }} + GITHUB_CLIENT_ID: {{ .Values.oauth.github.clientId | quote }} + GITHUB_CLIENT_SECRET: {{ .Values.oauth.github.clientSecret | quote }} + {{- end }} + {{- if .Values.oauth.google.clientId }} + GOOGLE_CLIENT_ID: {{ .Values.oauth.google.clientId | quote }} + GOOGLE_CLIENT_SECRET: {{ .Values.oauth.google.clientSecret | quote }} + {{- end }} + {{- if .Values.ai.openaiApiKey }} + OPENAI_API_KEY: {{ .Values.ai.openaiApiKey | quote }} + {{- end }} diff --git a/deploy/kubernetes/quackback/templates/service.yaml b/deploy/kubernetes/quackback/templates/service.yaml new file mode 100644 index 000000000..4cba49502 --- /dev/null +++ b/deploy/kubernetes/quackback/templates/service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "quackback.fullname" . }} + labels: + {{- include "quackback.labels" . | nindent 4 }} + app.kubernetes.io/component: app +spec: + type: ClusterIP + ports: + - port: {{ .Values.app.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "quackback.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: app diff --git a/deploy/kubernetes/quackback/templates/sla-tick-cronjob.yaml b/deploy/kubernetes/quackback/templates/sla-tick-cronjob.yaml new file mode 100644 index 000000000..197b2f69c --- /dev/null +++ b/deploy/kubernetes/quackback/templates/sla-tick-cronjob.yaml @@ -0,0 +1,54 @@ +{{/* +Optional replacement for the pg_cron-driven SLA tick described in +deploy/self-hosted/README.md ("Scheduled Jobs (pg_cron)"): that doc +explicitly calls out a Kubernetes CronJob as the alternative for operators +not running pg_cron. Disabled by default — enable it only if you're not +running pg_cron against the database Quackback uses. +*/}} +{{- if .Values.slaTickCronJob.enabled }} +{{- if not .Values.internalTaskSecret }} +{{- fail "internalTaskSecret is required when slaTickCronJob.enabled=true" }} +{{- end }} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "quackback.fullname" . }}-sla-tick + labels: + {{- include "quackback.labels" . | nindent 4 }} + app.kubernetes.io/component: sla-tick +spec: + schedule: {{ .Values.slaTickCronJob.schedule | quote }} + concurrencyPolicy: Forbid + jobTemplate: + spec: + backoffLimit: 2 + template: + metadata: + labels: + {{- include "quackback.selectorLabels" . | nindent 12 }} + app.kubernetes.io/component: sla-tick + spec: + restartPolicy: Never + containers: + - name: sla-tick + image: curlimages/curl:8.11.1 + env: + - name: INTERNAL_TASK_SECRET + valueFrom: + secretKeyRef: + name: {{ include "quackback.fullname" . }}-secret + key: INTERNAL_TASK_SECRET + args: + - -fsS + - -X + - POST + - {{ printf "%s/api/v1/internal/sla-tick" .Values.baseUrl }} + - -H + - "content-type: application/json" + - -H + - "x-internal-secret: $(INTERNAL_TASK_SECRET)" + - -d + - "{}" + resources: + {{- toYaml .Values.slaTickCronJob.resources | nindent 16 }} +{{- end }} diff --git a/deploy/kubernetes/quackback/values.yaml b/deploy/kubernetes/quackback/values.yaml new file mode 100644 index 000000000..544227b19 --- /dev/null +++ b/deploy/kubernetes/quackback/values.yaml @@ -0,0 +1,267 @@ +# Default values for the Quackback Helm chart. +# Field-by-field docs: ../README.md +# Environment variable reference: /.env.prod.example (repo root) + +nameOverride: "" +fullnameOverride: "" + +# Pull secrets for private image registries (app image and/or bundled +# datastore images, if you've mirrored them internally). +imagePullSecrets: [] +# - name: regcred + +# ────────────────────────────────────────────────────────────────────────── +# Required — no default. Set via --set or a values override file. +# ────────────────────────────────────────────────────────────────────────── + +# Session signing + encryption key, >= 32 chars. Generate with: +# openssl rand -base64 32 +secretKey: "" + +# Public URL of this instance. Auth, emails and OAuth callbacks depend on it. +baseUrl: "" + +# ────────────────────────────────────────────────────────────────────────── +# Quackback application +# ────────────────────────────────────────────────────────────────────────── +app: + replicaCount: 1 + image: + repository: ghcr.io/quackbackio/quackback + # Defaults to .Chart.AppVersion when empty. Pin to a release tag in + # production — see https://quackback.io/docs/self-hosting/docker#docker-images + # for the tag scheme (latest / latest-enterprise / vX.Y.Z / vX.Y.Z-enterprise). + tag: "" + pullPolicy: IfNotPresent + port: 3000 + + podAnnotations: {} + podLabels: {} + + # Matches the non-root `quackback` user (uid/gid 1001) created in + # apps/web/Dockerfile. + podSecurityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: ["ALL"] + + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi + + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 5 + targetCPUUtilizationPercentage: 75 + + podDisruptionBudget: + enabled: false + minAvailable: 1 + + nodeSelector: {} + tolerations: [] + affinity: {} + + # Escape hatch for anything not covered by an explicit value below — + # integrations (Slack/Linear/Jira/Discord/...), AI_*_MODEL overrides, + # EMAIL_INBOUND_*, etc. See .env.prod.example for the full variable list. + extraEnv: [] + # - name: DISCORD_WEBHOOK_URL + # value: https://discord.com/api/webhooks/... + extraEnvFrom: [] + # - secretRef: + # name: quackback-integrations + +disableTelemetry: "true" +seedDatabase: "false" + +# ────────────────────────────────────────────────────────────────────────── +# Migrations +# +# apps/web/Dockerfile documents the intended K8s pattern: run migrations from +# a pre-install/pre-upgrade Helm hook Job, and set SKIP_MIGRATIONS=true on the +# app Deployment so it never races the hook (or double-runs them per replica). +# This chart follows that pattern — the app's SKIP_MIGRATIONS is hardcoded to +# "true" whenever migrations.enabled is true. +# ────────────────────────────────────────────────────────────────────────── +migrations: + enabled: true + backoffLimit: 3 + activeDeadlineSeconds: 300 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: "1" + memory: 512Mi + +# ────────────────────────────────────────────────────────────────────────── +# Email — leave smtp.host and resendApiKey both empty to log OTP/invite codes +# to the pod's console instead of sending real email (fine for evaluation). +# ────────────────────────────────────────────────────────────────────────── +email: + smtp: + host: "" + port: "" + user: "" + pass: "" + secure: "" + resendApiKey: "" + from: "" + +# ────────────────────────────────────────────────────────────────────────── +# OAuth sign-in providers (optional) +# ────────────────────────────────────────────────────────────────────────── +oauth: + github: + clientId: "" + clientSecret: "" + google: + clientId: "" + clientSecret: "" + +# ────────────────────────────────────────────────────────────────────────── +# AI features (optional) — summaries, duplicate detection, feedback +# extraction. All AI features stay off until both an API key and at least +# one model are set; Quackback talks to any OpenAI-compatible endpoint. +# ────────────────────────────────────────────────────────────────────────── +ai: + openaiApiKey: "" + openaiBaseUrl: "" + chatModel: "" + embeddingModel: "" + +# ────────────────────────────────────────────────────────────────────────── +# The internal SLA-tick CronJob (below) and any external scheduler need this +# to call authenticated internal endpoints. Leave empty to disable both the +# CronJob and the endpoints it targets. +# ────────────────────────────────────────────────────────────────────────── +internalTaskSecret: "" + +# Drives the SLA escalation tick (POST /api/v1/internal/sla-tick once a +# minute) the way deploy/self-hosted/README.md recommends for operators who +# aren't running pg_cron: "drive the SLA tick from any external scheduler +# (systemd timer, Kubernetes CronJob, ...)". Requires internalTaskSecret. +slaTickCronJob: + enabled: false + schedule: "* * * * *" + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + cpu: 100m + memory: 64Mi + +# ────────────────────────────────────────────────────────────────────────── +# PostgreSQL — bundled StatefulSet, enabled by default so the chart is +# deployable with just secretKey/baseUrl/ingress.host. Set enabled: false and +# externalDatabaseUrl for a managed database (recommended for production — +# see the note in README.md about pg_cron and the bundled image). +# ────────────────────────────────────────────────────────────────────────── +postgres: + enabled: true + image: + repository: pgvector/pgvector + tag: pg17 + pullPolicy: IfNotPresent + user: "quackback" + password: "" # auto-generated at install time when left empty + database: "quackback" + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + storage: + size: 5Gi + # Empty uses the cluster's default StorageClass. + storageClassName: "" + +# Required when postgres.enabled is false. +externalDatabaseUrl: "" + +# ────────────────────────────────────────────────────────────────────────── +# Dragonfly — Redis-compatible store for the BullMQ job queue. +# ────────────────────────────────────────────────────────────────────────── +dragonfly: + enabled: true + image: + repository: docker.dragonflydb.io/dragonflydb/dragonfly + tag: v1.27.1 + pullPolicy: IfNotPresent + # 4 Dragonfly threads need at least ~1GiB; keep maxmemory above that floor. + maxmemory: "1280mb" + resources: + requests: + cpu: 50m + memory: 1536Mi + limits: + cpu: 200m + memory: 2Gi + storage: + size: 5Gi + storageClassName: "" + +# Required when dragonfly.enabled is false. +externalRedisUrl: "" + +# ────────────────────────────────────────────────────────────────────────── +# MinIO — bundled S3-compatible object storage for uploads (logos, avatars, +# changelog images). Traffic is proxied through the app (S3_PROXY) since the +# in-cluster MinIO has no public endpoint of its own. +# ────────────────────────────────────────────────────────────────────────── +minio: + enabled: true + image: + repository: minio/minio + tag: latest + pullPolicy: IfNotPresent + rootUser: "quackback" + rootPassword: "" # auto-generated at install time when left empty + bucketName: "quackback" + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + storage: + size: 20Gi + storageClassName: "" + +# ────────────────────────────────────────────────────────────────────────── +# Ingress +# ────────────────────────────────────────────────────────────────────────── +ingress: + enabled: true + className: "nginx" + # Extra annotations merged in verbatim — use this for cert-manager + # (cert-manager.io/cluster-issuer: ...) or controller-specific tuning. + annotations: {} + host: "" + tls: + enabled: true + secretName: "" + +# ────────────────────────────────────────────────────────────────────────── +# Scheduling shared by every bundled workload (app, postgres, dragonfly, +# minio). Per-component overrides live under app.nodeSelector etc. above. +# ────────────────────────────────────────────────────────────────────────── +nodeSelector: {} +tolerations: []