From dc1b9e9cc0c8dfcbe7aba74699718e45a357225c Mon Sep 17 00:00:00 2001 From: Amir Husayn Panahifar Date: Wed, 7 Oct 2026 19:40:26 +0330 Subject: [PATCH] feat(deploy): add Helm chart for k3s and production Kubernetes Include values profiles, secrets handling, TLS hooks, HPA/PDB, and in-cluster data services. --- deploy/README.md | 62 +++++ deploy/helm/pargar/.helmignore | 5 + deploy/helm/pargar/Chart.yaml | 15 ++ deploy/helm/pargar/templates/NOTES.txt | 26 ++ deploy/helm/pargar/templates/_helpers.tpl | 110 +++++++++ deploy/helm/pargar/templates/backend.yaml | 204 ++++++++++++++++ .../helm/pargar/templates/backup-cronjob.yaml | 79 ++++++ deploy/helm/pargar/templates/certificate.yaml | 23 ++ deploy/helm/pargar/templates/frontend.yaml | 83 +++++++ deploy/helm/pargar/templates/hpa.yaml | 67 +++++ deploy/helm/pargar/templates/ingress.yaml | 50 ++++ .../helm/pargar/templates/ingressroute.yaml | 97 ++++++++ deploy/helm/pargar/templates/middlewares.yaml | 63 +++++ .../helm/pargar/templates/networkpolicy.yaml | 229 ++++++++++++++++++ deploy/helm/pargar/templates/pdb.yaml | 41 ++++ deploy/helm/pargar/templates/postgres.yaml | 93 +++++++ deploy/helm/pargar/templates/redis.yaml | 88 +++++++ deploy/helm/pargar/templates/rustfs.yaml | 101 ++++++++ deploy/helm/pargar/templates/secret-app.yaml | 56 +++++ deploy/helm/pargar/templates/secret-data.yaml | 71 ++++++ .../helm/pargar/templates/serviceaccount.yaml | 12 + deploy/helm/pargar/templates/tls-secret.yaml | 21 ++ deploy/helm/pargar/values-k3s.yaml | 55 +++++ deploy/helm/pargar/values-prod.yaml | 103 ++++++++ deploy/helm/pargar/values.yaml | 229 ++++++++++++++++++ 25 files changed, 1983 insertions(+) create mode 100644 deploy/README.md create mode 100644 deploy/helm/pargar/.helmignore create mode 100644 deploy/helm/pargar/Chart.yaml create mode 100644 deploy/helm/pargar/templates/NOTES.txt create mode 100644 deploy/helm/pargar/templates/_helpers.tpl create mode 100644 deploy/helm/pargar/templates/backend.yaml create mode 100644 deploy/helm/pargar/templates/backup-cronjob.yaml create mode 100644 deploy/helm/pargar/templates/certificate.yaml create mode 100644 deploy/helm/pargar/templates/frontend.yaml create mode 100644 deploy/helm/pargar/templates/hpa.yaml create mode 100644 deploy/helm/pargar/templates/ingress.yaml create mode 100644 deploy/helm/pargar/templates/ingressroute.yaml create mode 100644 deploy/helm/pargar/templates/middlewares.yaml create mode 100644 deploy/helm/pargar/templates/networkpolicy.yaml create mode 100644 deploy/helm/pargar/templates/pdb.yaml create mode 100644 deploy/helm/pargar/templates/postgres.yaml create mode 100644 deploy/helm/pargar/templates/redis.yaml create mode 100644 deploy/helm/pargar/templates/rustfs.yaml create mode 100644 deploy/helm/pargar/templates/secret-app.yaml create mode 100644 deploy/helm/pargar/templates/secret-data.yaml create mode 100644 deploy/helm/pargar/templates/serviceaccount.yaml create mode 100644 deploy/helm/pargar/templates/tls-secret.yaml create mode 100644 deploy/helm/pargar/values-k3s.yaml create mode 100644 deploy/helm/pargar/values-prod.yaml create mode 100644 deploy/helm/pargar/values.yaml diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..e0519fb --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,62 @@ +# Pargar deploy + +Full deployment guide and production checklist: **[`docs/deployment.md`](../docs/deployment.md)** +Security & secrets: [`docs/security.md`](../docs/security.md) · Ops / backup: [`docs/operations.md`](../docs/operations.md) + +Edge: **Traefik v3.7** (Compose needs Traefik ≥3.6.1 on Docker Engine 29+). + +## Layout + +```text +deploy/ + compose/ + compose.yaml # shared app + data + Traefik labels + compose.dev.yaml # laptop TLS default, DB ports on 127.0.0.1 + compose.prod.yaml # Let's Encrypt, no host DB ports + traefik/dynamic/ # middlewares + helm/pargar/ # chart + values-k3s / values-prod +scripts/ + compose-dev.sh compose-prod.sh k3s-load-images.sh helm-k3s.sh backup.sh +``` + +## Compose — development + +```bash +cp .env.example .env +./scripts/compose-dev.sh up -d --build +``` + +- App: https://localhost +- Traefik dashboard: http://127.0.0.1:8088 + +## Compose — production + +```bash +cp .env.prod.example .env.prod +./scripts/compose-prod.sh up -d --build +``` + +## k3s + Helm + +```bash +./scripts/k3s-load-images.sh +./scripts/helm-k3s.sh +curl -k https://pargar.local/api/health +``` + +```bash +kubectl -n pargar get secret pargar-app -o jsonpath='{.data.admin-password}' | base64 -d; echo +``` + +Values: `values.yaml` · `values-k3s.yaml` · `values-prod.yaml` +Lint: `helm lint ./deploy/helm/pargar -f ./deploy/helm/pargar/values-k3s.yaml` + +## Routing + +| Path | Service | +|------|---------| +| `/api/*`, `/media/*` | backend | +| `/api/certificates/*`, `/c/*` | backend + noindex | +| else | frontend | + +WS: `/api/ws` via Traefik. diff --git a/deploy/helm/pargar/.helmignore b/deploy/helm/pargar/.helmignore new file mode 100644 index 0000000..0633569 --- /dev/null +++ b/deploy/helm/pargar/.helmignore @@ -0,0 +1,5 @@ +.DS_Store +.git +*.md +*.tgz +secrets*.yaml diff --git a/deploy/helm/pargar/Chart.yaml b/deploy/helm/pargar/Chart.yaml new file mode 100644 index 0000000..43ab2a4 --- /dev/null +++ b/deploy/helm/pargar/Chart.yaml @@ -0,0 +1,15 @@ +apiVersion: v2 +name: pargar +description: Pargar gated LMS - Go API, Next.js frontend, Postgres, Redis, S3-compatible storage +type: application +version: 0.2.0 +appVersion: "0.1.0" +keywords: + - lms + - pargar + - traefik +maintainers: + - name: Pargar +home: https://github.com/example/pargar +sources: + - https://github.com/example/pargar diff --git a/deploy/helm/pargar/templates/NOTES.txt b/deploy/helm/pargar/templates/NOTES.txt new file mode 100644 index 0000000..daca5f8 --- /dev/null +++ b/deploy/helm/pargar/templates/NOTES.txt @@ -0,0 +1,26 @@ +Pargar {{ .Chart.AppVersion }} installed as release "{{ .Release.Name }}" in namespace "{{ .Release.Namespace }}". + +Public URL: {{ .Values.global.publicUrl }} +Domain: {{ .Values.global.domain }} + +1) Ensure DNS /hosts points to your Traefik entrypoint: + {{ .Values.global.domain }} → Traefik LoadBalancer / node IP + +2) Images must exist on the cluster nodes (k3s local): + docker compose -f deploy/compose/compose.yaml -f deploy/compose/compose.dev.yaml build + docker save pargar-backend:local pargar-frontend:local | sudo k3s ctr images import - + +3) Upgrade / install: + helm upgrade --install {{ .Release.Name }} ./deploy/helm/pargar \ + -n {{ .Release.Namespace }} --create-namespace \ + -f deploy/helm/pargar/values-k3s.yaml + +4) Health: + kubectl -n {{ .Release.Namespace }} get pods,ingressroute,svc + curl -k {{ .Values.global.publicUrl }}/api/health + +{{- if not .Values.ingress.tls.existingSecret }} + +TLS: chart-managed self-signed certificate (browser warning expected). +For production set ingress.tls.existingSecret (cert-manager). +{{- end }} diff --git a/deploy/helm/pargar/templates/_helpers.tpl b/deploy/helm/pargar/templates/_helpers.tpl new file mode 100644 index 0000000..f14b778 --- /dev/null +++ b/deploy/helm/pargar/templates/_helpers.tpl @@ -0,0 +1,110 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "pargar.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "pargar.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 }} + +{{- define "pargar.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "pargar.labels" -}} +helm.sh/chart: {{ include "pargar.chart" . }} +{{ include "pargar.selectorLabels" . }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "pargar.selectorLabels" -}} +app.kubernetes.io/name: {{ include "pargar.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "pargar.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "pargar.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{- define "pargar.appSecretName" -}} +{{- if .Values.backend.existingSecret }} +{{- .Values.backend.existingSecret }} +{{- else }} +{{- printf "%s-app" (include "pargar.fullname" .) }} +{{- end }} +{{- end }} + +{{- define "pargar.postgresSecretName" -}} +{{- if .Values.postgres.existingSecret }} +{{- .Values.postgres.existingSecret }} +{{- else }} +{{- printf "%s-postgres" (include "pargar.fullname" .) }} +{{- end }} +{{- end }} + +{{- define "pargar.redisSecretName" -}} +{{- if .Values.redis.existingSecret }} +{{- .Values.redis.existingSecret }} +{{- else }} +{{- printf "%s-redis" (include "pargar.fullname" .) }} +{{- end }} +{{- end }} + +{{- define "pargar.rustfsSecretName" -}} +{{- if .Values.rustfs.existingSecret }} +{{- .Values.rustfs.existingSecret }} +{{- else }} +{{- printf "%s-rustfs" (include "pargar.fullname" .) }} +{{- end }} +{{- end }} + +{{- define "pargar.tlsSecretName" -}} +{{- if .Values.ingress.tls.existingSecret }} +{{- .Values.ingress.tls.existingSecret }} +{{- else }} +{{- printf "%s-tls" (include "pargar.fullname" .) }} +{{- end }} +{{- end }} + +{{- define "pargar.postgresHost" -}} +{{- printf "%s-postgres" (include "pargar.fullname" .) }} +{{- end }} + +{{- define "pargar.redisHost" -}} +{{- printf "%s-redis" (include "pargar.fullname" .) }} +{{- end }} + +{{- define "pargar.rustfsHost" -}} +{{- printf "%s-rustfs" (include "pargar.fullname" .) }} +{{- end }} + +{{/* +Resolve container image: optional global.imageRegistry + repository:tag +Usage: {{ include "pargar.image" (dict "root" . "image" .Values.backend.image) }} +*/}} +{{- define "pargar.image" -}} +{{- $registry := .root.Values.global.imageRegistry | default "" -}} +{{- $repo := .image.repository -}} +{{- $tag := .image.tag | default .root.Chart.AppVersion | toString -}} +{{- if $registry -}} +{{- printf "%s/%s:%s" (trimSuffix "/" $registry) $repo $tag -}} +{{- else -}} +{{- printf "%s:%s" $repo $tag -}} +{{- end -}} +{{- end }} diff --git a/deploy/helm/pargar/templates/backend.yaml b/deploy/helm/pargar/templates/backend.yaml new file mode 100644 index 0000000..7e1e770 --- /dev/null +++ b/deploy/helm/pargar/templates/backend.yaml @@ -0,0 +1,204 @@ +{{- if .Values.backend.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "pargar.fullname" . }}-backend + labels: + {{- include "pargar.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + type: ClusterIP + ports: + - name: http + port: {{ .Values.backend.service.port }} + targetPort: http + selector: + {{- include "pargar.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: backend +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "pargar.fullname" . }}-backend + labels: + {{- include "pargar.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + {{- if not .Values.backend.autoscaling.enabled }} + replicas: {{ .Values.backend.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "pargar.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: backend + template: + metadata: + labels: + {{- include "pargar.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: backend + spec: + serviceAccountName: {{ include "pargar.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: backend + image: {{ include "pargar.image" (dict "root" . "image" .Values.backend.image) | quote }} + imagePullPolicy: {{ .Values.backend.image.pullPolicy }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + ports: + - name: http + containerPort: {{ .Values.backend.service.port }} + env: + - name: PORT + value: {{ .Values.backend.service.port | quote }} + - name: PUBLIC_URL + value: {{ .Values.global.publicUrl | quote }} + - name: FRONTEND_ORIGINS + value: {{ .Values.global.frontendOrigins | quote }} + - name: TRUSTED_PROXY_CIDRS + value: {{ .Values.global.trustedProxyCIDRs | quote }} + - name: DEV_MODE + value: {{ .Values.backend.env.devMode | quote }} + - name: SEED_CURRICULUM + value: {{ .Values.backend.env.seedCurriculum | quote }} + - name: ADMIN_NAME + value: {{ .Values.backend.admin.name | quote }} + - name: ADMIN_USERNAME + value: {{ .Values.backend.admin.username | quote }} + - name: ADMIN_EMAIL + value: {{ .Values.backend.admin.email | quote }} + - name: JWT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "pargar.appSecretName" . }} + key: jwt-secret + - name: ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "pargar.appSecretName" . }} + key: admin-password + - name: METRICS_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "pargar.appSecretName" . }} + key: metrics-token + optional: true + - name: DONATION_ENABLED + value: {{ .Values.backend.env.donationEnabled | quote }} + - name: PHYSICAL_CERT_ENABLED + value: {{ .Values.backend.env.physicalCertEnabled | quote }} + - name: PHYSICAL_CERT_PRICE_IRR + value: {{ .Values.backend.env.physicalCertPriceIrr | quote }} + - name: PHYSICAL_CERT_WINDOW_DAYS + value: {{ .Values.backend.env.physicalCertWindowDays | quote }} + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: {{ .Values.backend.env.otelEndpoint | quote }} + - name: OTEL_SERVICE_NAME + value: {{ .Values.backend.env.otelServiceName | quote }} + - name: STORAGE_DRIVER + value: s3 + - name: STORAGE_REGION + value: {{ .Values.rustfs.region | quote }} + - name: STORAGE_BUCKET + value: {{ .Values.rustfs.bucket | quote }} + {{- if .Values.postgres.enabled }} + - name: POSTGRES_USER + value: {{ .Values.postgres.auth.username | quote }} + - name: POSTGRES_DB + value: {{ .Values.postgres.auth.database | quote }} + - name: POSTGRES_HOST + value: {{ include "pargar.postgresHost" . | quote }} + - name: POSTGRES_PORT + value: "5432" + - name: POSTGRES_SSLMODE + value: "disable" + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "pargar.postgresSecretName" . }} + key: postgres-password + {{- else }} + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ include "pargar.appSecretName" . }} + key: database-url + {{- end }} + {{- if .Values.redis.enabled }} + - name: REDIS_HOST + value: {{ include "pargar.redisHost" . | quote }} + - name: REDIS_PORT + value: "6379" + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "pargar.redisSecretName" . }} + key: redis-password + {{- else }} + - name: REDIS_URL + valueFrom: + secretKeyRef: + name: {{ include "pargar.appSecretName" . }} + key: redis-url + {{- end }} + {{- if .Values.rustfs.enabled }} + - name: STORAGE_ENDPOINT + value: {{ printf "http://%s:9000" (include "pargar.rustfsHost" .) | quote }} + - name: STORAGE_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ include "pargar.rustfsSecretName" . }} + key: access-key + - name: STORAGE_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ include "pargar.rustfsSecretName" . }} + key: secret-key + {{- else }} + - name: STORAGE_ENDPOINT + value: {{ required "external.storageEndpoint required when rustfs.enabled=false" .Values.external.storageEndpoint | quote }} + - name: STORAGE_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ include "pargar.appSecretName" . }} + key: storage-access-key + - name: STORAGE_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ include "pargar.appSecretName" . }} + key: storage-secret-key + {{- end }} + readinessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 15 + periodSeconds: 10 + {{- with .Values.backend.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- 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 }} +{{- end }} diff --git a/deploy/helm/pargar/templates/backup-cronjob.yaml b/deploy/helm/pargar/templates/backup-cronjob.yaml new file mode 100644 index 0000000..ab352bb --- /dev/null +++ b/deploy/helm/pargar/templates/backup-cronjob.yaml @@ -0,0 +1,79 @@ +{{- if and .Values.postgres.enabled .Values.backup.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "pargar.fullname" . }}-backups + labels: + {{- include "pargar.labels" . | nindent 4 }} + app.kubernetes.io/component: backup +spec: + accessModes: ["ReadWriteOnce"] + {{- if .Values.backup.persistence.storageClass }} + storageClassName: {{ .Values.backup.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.backup.persistence.size | quote }} +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "pargar.fullname" . }}-pg-backup + labels: + {{- include "pargar.labels" . | nindent 4 }} + app.kubernetes.io/component: backup +spec: + schedule: {{ .Values.backup.schedule | quote }} + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + template: + metadata: + labels: + {{- include "pargar.selectorLabels" . | nindent 12 }} + app.kubernetes.io/component: backup + spec: + restartPolicy: OnFailure + securityContext: + fsGroup: 999 + containers: + - name: pg-dump + image: {{ .Values.backup.image | quote }} + imagePullPolicy: IfNotPresent + env: + - name: PGHOST + value: {{ include "pargar.postgresHost" . | quote }} + - name: PGPORT + value: "5432" + - name: PGUSER + value: {{ .Values.postgres.auth.username | quote }} + - name: PGDATABASE + value: {{ .Values.postgres.auth.database | quote }} + - name: RETENTION_DAYS + value: {{ .Values.backup.retentionDays | quote }} + - name: PGPASSWORD + valueFrom: + secretKeyRef: + name: {{ include "pargar.postgresSecretName" . }} + key: postgres-password + command: + - /bin/sh + - -ec + - | + set -euo pipefail + STAMP="$(date -u +%Y%m%dT%H%M%SZ)" + OUT="/backups/pargar-${STAMP}.sql.gz" + echo "Writing ${OUT}" + pg_dump --no-owner --no-acl | gzip -c > "${OUT}" + find /backups -type f -name 'pargar-*.sql.gz' -mtime +"${RETENTION_DAYS}" -print -delete || true + ls -lh /backups || true + volumeMounts: + - name: backups + mountPath: /backups + volumes: + - name: backups + persistentVolumeClaim: + claimName: {{ include "pargar.fullname" . }}-backups +{{- end }} diff --git a/deploy/helm/pargar/templates/certificate.yaml b/deploy/helm/pargar/templates/certificate.yaml new file mode 100644 index 0000000..9f5524b --- /dev/null +++ b/deploy/helm/pargar/templates/certificate.yaml @@ -0,0 +1,23 @@ +{{- if and .Values.ingress.enabled .Values.ingress.tls.enabled .Values.certManager.enabled }} +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ include "pargar.fullname" . }}-tls + labels: + {{- include "pargar.labels" . | nindent 4 }} +spec: + secretName: {{ include "pargar.tlsSecretName" . }} + duration: {{ .Values.certManager.duration | default "2160h" | quote }} + renewBefore: {{ .Values.certManager.renewBefore | default "360h" | quote }} + issuerRef: + name: {{ required "certManager.issuerRef.name required when certManager.enabled" .Values.certManager.issuerRef.name | quote }} + kind: {{ .Values.certManager.issuerRef.kind | default "ClusterIssuer" | quote }} + {{- if .Values.certManager.issuerRef.group }} + group: {{ .Values.certManager.issuerRef.group | quote }} + {{- end }} + dnsNames: + - {{ .Values.global.domain | quote }} + {{- range .Values.certManager.extraDnsNames }} + - {{ . | quote }} + {{- end }} +{{- end }} diff --git a/deploy/helm/pargar/templates/frontend.yaml b/deploy/helm/pargar/templates/frontend.yaml new file mode 100644 index 0000000..ab9abb5 --- /dev/null +++ b/deploy/helm/pargar/templates/frontend.yaml @@ -0,0 +1,83 @@ +{{- if .Values.frontend.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "pargar.fullname" . }}-frontend + labels: + {{- include "pargar.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + type: ClusterIP + ports: + - name: http + port: {{ .Values.frontend.service.port }} + targetPort: http + selector: + {{- include "pargar.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: frontend +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "pargar.fullname" . }}-frontend + labels: + {{- include "pargar.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + {{- if not .Values.frontend.autoscaling.enabled }} + replicas: {{ .Values.frontend.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "pargar.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: frontend + template: + metadata: + labels: + {{- include "pargar.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: frontend + spec: + serviceAccountName: {{ include "pargar.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: frontend + image: {{ include "pargar.image" (dict "root" . "image" .Values.frontend.image) | quote }} + imagePullPolicy: {{ .Values.frontend.image.pullPolicy }} + env: + - name: NODE_ENV + value: production + ports: + - name: http + containerPort: {{ .Values.frontend.service.port }} + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 15 + periodSeconds: 10 + {{- with .Values.frontend.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- 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 }} +{{- end }} diff --git a/deploy/helm/pargar/templates/hpa.yaml b/deploy/helm/pargar/templates/hpa.yaml new file mode 100644 index 0000000..64090cd --- /dev/null +++ b/deploy/helm/pargar/templates/hpa.yaml @@ -0,0 +1,67 @@ +{{- if and .Values.backend.enabled .Values.backend.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "pargar.fullname" . }}-backend + labels: + {{- include "pargar.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "pargar.fullname" . }}-backend + minReplicas: {{ .Values.backend.autoscaling.minReplicas }} + maxReplicas: {{ .Values.backend.autoscaling.maxReplicas }} + metrics: + {{- if .Values.backend.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.backend.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.backend.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.backend.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +--- +{{- end }} +{{- if and .Values.frontend.enabled .Values.frontend.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "pargar.fullname" . }}-frontend + labels: + {{- include "pargar.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "pargar.fullname" . }}-frontend + minReplicas: {{ .Values.frontend.autoscaling.minReplicas }} + maxReplicas: {{ .Values.frontend.autoscaling.maxReplicas }} + metrics: + {{- if .Values.frontend.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.frontend.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.frontend.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.frontend.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/deploy/helm/pargar/templates/ingress.yaml b/deploy/helm/pargar/templates/ingress.yaml new file mode 100644 index 0000000..65c34a4 --- /dev/null +++ b/deploy/helm/pargar/templates/ingress.yaml @@ -0,0 +1,50 @@ +{{- if and .Values.ingress.enabled (not .Values.ingress.useIngressRoute) }} +{{- $fullName := include "pargar.fullname" . }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "pargar.labels" . | nindent 4 }} + annotations: + traefik.ingress.kubernetes.io/router.middlewares: {{ .Release.Namespace }}-{{ $fullName }}-security-headers@kubernetescrd,{{ .Release.Namespace }}-{{ $fullName }}-compress@kubernetescrd + {{- if .Values.ingress.tls.clusterIssuer }} + cert-manager.io/cluster-issuer: {{ .Values.ingress.tls.clusterIssuer | quote }} + {{- end }} + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + ingressClassName: {{ .Values.ingress.className | quote }} + {{- if .Values.ingress.tls.enabled }} + tls: + - hosts: + - {{ .Values.global.domain | quote }} + secretName: {{ include "pargar.tlsSecretName" . }} + {{- end }} + rules: + - host: {{ .Values.global.domain | quote }} + http: + paths: + - path: /api + pathType: Prefix + backend: + service: + name: {{ $fullName }}-backend + port: + number: {{ .Values.backend.service.port }} + - path: /media + pathType: Prefix + backend: + service: + name: {{ $fullName }}-backend + port: + number: {{ .Values.backend.service.port }} + - path: / + pathType: Prefix + backend: + service: + name: {{ $fullName }}-frontend + port: + number: {{ .Values.frontend.service.port }} +{{- end }} diff --git a/deploy/helm/pargar/templates/ingressroute.yaml b/deploy/helm/pargar/templates/ingressroute.yaml new file mode 100644 index 0000000..dabb172 --- /dev/null +++ b/deploy/helm/pargar/templates/ingressroute.yaml @@ -0,0 +1,97 @@ +{{- if and .Values.ingress.enabled .Values.ingress.useIngressRoute }} +{{- $fullName := include "pargar.fullname" . }} +{{- $domain := .Values.global.domain }} +{{- $tlsSecret := include "pargar.tlsSecretName" . }} +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: {{ $fullName }}-http + labels: + {{- include "pargar.labels" . | nindent 4 }} +spec: + entryPoints: + - web + routes: + - match: Host(`{{ $domain }}`) + kind: Rule + {{- if .Values.middlewares.redirectHttps }} + middlewares: + - name: {{ $fullName }}-redirect-https + {{- end }} + services: + - name: {{ $fullName }}-frontend + port: {{ .Values.frontend.service.port }} +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: {{ $fullName }}-https + labels: + {{- include "pargar.labels" . | nindent 4 }} +spec: + entryPoints: + - websecure + routes: + # Certificate public API — highest priority + noindex + - match: Host(`{{ $domain }}`) && PathPrefix(`/api/certificates`) + kind: Rule + priority: 110 + middlewares: + {{- if .Values.middlewares.securityHeaders }} + - name: {{ $fullName }}-security-headers + {{- end }} + {{- if .Values.middlewares.compress }} + - name: {{ $fullName }}-compress + {{- end }} + - name: {{ $fullName }}-noindex + services: + - name: {{ $fullName }}-backend + port: {{ .Values.backend.service.port }} + # API + media (+ WebSocket /api/ws) + - match: Host(`{{ $domain }}`) && (PathPrefix(`/api`) || PathPrefix(`/media`)) + kind: Rule + priority: 100 + middlewares: + {{- if .Values.middlewares.securityHeaders }} + - name: {{ $fullName }}-security-headers + {{- end }} + {{- if .Values.middlewares.compress }} + - name: {{ $fullName }}-compress + {{- end }} + services: + - name: {{ $fullName }}-backend + port: {{ .Values.backend.service.port }} + # Unlisted certificate pages + - match: Host(`{{ $domain }}`) && PathPrefix(`/c`) + kind: Rule + priority: 50 + middlewares: + {{- if .Values.middlewares.securityHeaders }} + - name: {{ $fullName }}-security-headers + {{- end }} + {{- if .Values.middlewares.compress }} + - name: {{ $fullName }}-compress + {{- end }} + - name: {{ $fullName }}-noindex + services: + - name: {{ $fullName }}-frontend + port: {{ .Values.frontend.service.port }} + # Frontend catch-all + - match: Host(`{{ $domain }}`) + kind: Rule + priority: 1 + middlewares: + {{- if .Values.middlewares.securityHeaders }} + - name: {{ $fullName }}-security-headers + {{- end }} + {{- if .Values.middlewares.compress }} + - name: {{ $fullName }}-compress + {{- end }} + services: + - name: {{ $fullName }}-frontend + port: {{ .Values.frontend.service.port }} + {{- if .Values.ingress.tls.enabled }} + tls: + secretName: {{ $tlsSecret }} + {{- end }} +{{- end }} diff --git a/deploy/helm/pargar/templates/middlewares.yaml b/deploy/helm/pargar/templates/middlewares.yaml new file mode 100644 index 0000000..0018f44 --- /dev/null +++ b/deploy/helm/pargar/templates/middlewares.yaml @@ -0,0 +1,63 @@ +{{- if .Values.ingress.enabled }} +{{- if .Values.middlewares.securityHeaders }} +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: {{ include "pargar.fullname" . }}-security-headers + labels: + {{- include "pargar.labels" . | nindent 4 }} +spec: + headers: + stsSeconds: 31536000 + stsIncludeSubdomains: true + contentTypeNosniff: true + browserXssFilter: true + frameDeny: true + referrerPolicy: strict-origin-when-cross-origin + permissionsPolicy: "camera=(), microphone=(), geolocation=()" + customResponseHeaders: + Server: "" + Content-Security-Policy: >- + default-src 'self'; base-uri 'self'; form-action 'self'; + frame-ancestors 'none'; object-src 'none'; + script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; + img-src 'self' data: blob:; font-src 'self' data:; + media-src 'self' blob:; connect-src 'self' ws: wss:; + worker-src 'self' blob: +--- +{{- end }} +{{- if .Values.middlewares.compress }} +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: {{ include "pargar.fullname" . }}-compress + labels: + {{- include "pargar.labels" . | nindent 4 }} +spec: + compress: {} +--- +{{- end }} +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: {{ include "pargar.fullname" . }}-noindex + labels: + {{- include "pargar.labels" . | nindent 4 }} +spec: + headers: + customResponseHeaders: + X-Robots-Tag: "noindex, nofollow, noarchive" +--- +{{- if and .Values.ingress.useIngressRoute .Values.middlewares.redirectHttps }} +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: {{ include "pargar.fullname" . }}-redirect-https + labels: + {{- include "pargar.labels" . | nindent 4 }} +spec: + redirectScheme: + scheme: https + permanent: true +{{- end }} +{{- end }} diff --git a/deploy/helm/pargar/templates/networkpolicy.yaml b/deploy/helm/pargar/templates/networkpolicy.yaml new file mode 100644 index 0000000..9799d13 --- /dev/null +++ b/deploy/helm/pargar/templates/networkpolicy.yaml @@ -0,0 +1,229 @@ +{{- if .Values.networkPolicy.enabled }} +{{- $fullName := include "pargar.fullname" . }} +# Default deny all ingress/egress in the release namespace, then allow required paths. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ $fullName }}-default-deny + labels: + {{- include "pargar.labels" . | nindent 4 }} +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress +--- +{{- if .Values.backend.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ $fullName }}-backend + labels: + {{- include "pargar.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + podSelector: + matchLabels: + {{- include "pargar.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: backend + policyTypes: + - Ingress + - Egress + ingress: + - from: + {{- range .Values.networkPolicy.ingressNamespaces }} + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ . | quote }} + podSelector: + matchLabels: + {{- toYaml $.Values.networkPolicy.ingressPodLabels | nindent 14 }} + {{- end }} + ports: + - protocol: TCP + port: {{ .Values.backend.service.port }} + egress: + # DNS + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + {{- if .Values.postgres.enabled }} + - to: + - podSelector: + matchLabels: + {{- include "pargar.selectorLabels" . | nindent 14 }} + app.kubernetes.io/component: postgres + ports: + - protocol: TCP + port: 5432 + {{- end }} + {{- if .Values.redis.enabled }} + - to: + - podSelector: + matchLabels: + {{- include "pargar.selectorLabels" . | nindent 14 }} + app.kubernetes.io/component: redis + ports: + - protocol: TCP + port: 6379 + {{- end }} + {{- if .Values.rustfs.enabled }} + - to: + - podSelector: + matchLabels: + {{- include "pargar.selectorLabels" . | nindent 14 }} + app.kubernetes.io/component: rustfs + ports: + - protocol: TCP + port: 9000 + {{- else }} + # External S3 / managed data when in-cluster rustfs is off + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + - 169.254.0.0/16 + ports: + - protocol: TCP + port: 443 + - protocol: TCP + port: 5432 + - protocol: TCP + port: 6379 + {{- end }} +--- +{{- end }} +{{- if .Values.frontend.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ $fullName }}-frontend + labels: + {{- include "pargar.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + podSelector: + matchLabels: + {{- include "pargar.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: frontend + policyTypes: + - Ingress + - Egress + ingress: + - from: + {{- range .Values.networkPolicy.ingressNamespaces }} + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ . | quote }} + podSelector: + matchLabels: + {{- toYaml $.Values.networkPolicy.ingressPodLabels | nindent 14 }} + {{- end }} + ports: + - protocol: TCP + port: {{ .Values.frontend.service.port }} + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +--- +{{- end }} +{{- if .Values.postgres.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ $fullName }}-postgres + labels: + {{- include "pargar.labels" . | nindent 4 }} + app.kubernetes.io/component: postgres +spec: + podSelector: + matchLabels: + {{- include "pargar.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: postgres + policyTypes: + - Ingress + - Egress + ingress: + - from: + - podSelector: + matchLabels: + {{- include "pargar.selectorLabels" . | nindent 14 }} + app.kubernetes.io/component: backend + ports: + - protocol: TCP + port: 5432 + egress: [] +--- +{{- end }} +{{- if .Values.redis.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ $fullName }}-redis + labels: + {{- include "pargar.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + podSelector: + matchLabels: + {{- include "pargar.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: redis + policyTypes: + - Ingress + - Egress + ingress: + - from: + - podSelector: + matchLabels: + {{- include "pargar.selectorLabels" . | nindent 14 }} + app.kubernetes.io/component: backend + ports: + - protocol: TCP + port: 6379 + egress: [] +--- +{{- end }} +{{- if .Values.rustfs.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ $fullName }}-rustfs + labels: + {{- include "pargar.labels" . | nindent 4 }} + app.kubernetes.io/component: rustfs +spec: + podSelector: + matchLabels: + {{- include "pargar.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: rustfs + policyTypes: + - Ingress + - Egress + ingress: + - from: + - podSelector: + matchLabels: + {{- include "pargar.selectorLabels" . | nindent 14 }} + app.kubernetes.io/component: backend + ports: + - protocol: TCP + port: 9000 + egress: [] +{{- end }} +{{- end }} diff --git a/deploy/helm/pargar/templates/pdb.yaml b/deploy/helm/pargar/templates/pdb.yaml new file mode 100644 index 0000000..9d9f640 --- /dev/null +++ b/deploy/helm/pargar/templates/pdb.yaml @@ -0,0 +1,41 @@ +{{- if and .Values.backend.enabled .Values.backend.podDisruptionBudget.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "pargar.fullname" . }}-backend + labels: + {{- include "pargar.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + {{- if .Values.backend.podDisruptionBudget.minAvailable }} + minAvailable: {{ .Values.backend.podDisruptionBudget.minAvailable }} + {{- end }} + {{- if .Values.backend.podDisruptionBudget.maxUnavailable }} + maxUnavailable: {{ .Values.backend.podDisruptionBudget.maxUnavailable }} + {{- end }} + selector: + matchLabels: + {{- include "pargar.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: backend +--- +{{- end }} +{{- if and .Values.frontend.enabled .Values.frontend.podDisruptionBudget.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "pargar.fullname" . }}-frontend + labels: + {{- include "pargar.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + {{- if .Values.frontend.podDisruptionBudget.minAvailable }} + minAvailable: {{ .Values.frontend.podDisruptionBudget.minAvailable }} + {{- end }} + {{- if .Values.frontend.podDisruptionBudget.maxUnavailable }} + maxUnavailable: {{ .Values.frontend.podDisruptionBudget.maxUnavailable }} + {{- end }} + selector: + matchLabels: + {{- include "pargar.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: frontend +{{- end }} diff --git a/deploy/helm/pargar/templates/postgres.yaml b/deploy/helm/pargar/templates/postgres.yaml new file mode 100644 index 0000000..0579b8a --- /dev/null +++ b/deploy/helm/pargar/templates/postgres.yaml @@ -0,0 +1,93 @@ +{{- if .Values.postgres.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "pargar.postgresHost" . }} + labels: + {{- include "pargar.labels" . | nindent 4 }} + app.kubernetes.io/component: postgres +spec: + type: ClusterIP + ports: + - name: postgres + port: 5432 + targetPort: postgres + selector: + {{- include "pargar.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: postgres +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "pargar.fullname" . }}-postgres + labels: + {{- include "pargar.labels" . | nindent 4 }} + app.kubernetes.io/component: postgres +spec: + serviceName: {{ include "pargar.postgresHost" . }} + replicas: 1 + selector: + matchLabels: + {{- include "pargar.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: postgres + template: + metadata: + labels: + {{- include "pargar.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: postgres + spec: + containers: + - name: postgres + image: {{ .Values.postgres.image }} + imagePullPolicy: IfNotPresent + ports: + - name: postgres + containerPort: 5432 + env: + - name: POSTGRES_USER + value: {{ .Values.postgres.auth.username | quote }} + - name: POSTGRES_DB + value: {{ .Values.postgres.auth.database | quote }} + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "pargar.postgresSecretName" . }} + key: postgres-password + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + readinessProbe: + exec: + command: ["pg_isready", "-U", {{ .Values.postgres.auth.username | quote }}, "-d", {{ .Values.postgres.auth.database | quote }}] + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + exec: + command: ["pg_isready", "-U", {{ .Values.postgres.auth.username | quote }}, "-d", {{ .Values.postgres.auth.database | quote }}] + initialDelaySeconds: 15 + periodSeconds: 10 + {{- with .Values.postgres.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + {{- if not .Values.postgres.persistence.enabled }} + volumes: + - name: data + emptyDir: {} + {{- end }} + {{- if .Values.postgres.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + {{- if .Values.postgres.persistence.storageClass }} + storageClassName: {{ .Values.postgres.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.postgres.persistence.size }} + {{- end }} +{{- end }} diff --git a/deploy/helm/pargar/templates/redis.yaml b/deploy/helm/pargar/templates/redis.yaml new file mode 100644 index 0000000..f9a5cd8 --- /dev/null +++ b/deploy/helm/pargar/templates/redis.yaml @@ -0,0 +1,88 @@ +{{- if .Values.redis.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "pargar.redisHost" . }} + labels: + {{- include "pargar.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + type: ClusterIP + ports: + - name: redis + port: 6379 + targetPort: redis + selector: + {{- include "pargar.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: redis +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "pargar.fullname" . }}-redis + labels: + {{- include "pargar.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + serviceName: {{ include "pargar.redisHost" . }} + replicas: 1 + selector: + matchLabels: + {{- include "pargar.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: redis + template: + metadata: + labels: + {{- include "pargar.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: redis + spec: + containers: + - name: redis + image: {{ .Values.redis.image }} + imagePullPolicy: IfNotPresent + command: ["sh", "-c", "exec redis-server --requirepass \"$REDIS_PASSWORD\" --appendonly yes"] + ports: + - name: redis + containerPort: 6379 + env: + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "pargar.redisSecretName" . }} + key: redis-password + readinessProbe: + exec: + command: ["sh", "-c", "redis-cli -a \"$REDIS_PASSWORD\" ping | grep -q PONG"] + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + exec: + command: ["sh", "-c", "redis-cli -a \"$REDIS_PASSWORD\" ping | grep -q PONG"] + initialDelaySeconds: 15 + periodSeconds: 10 + {{- with .Values.redis.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: data + mountPath: /data + {{- if not .Values.redis.persistence.enabled }} + volumes: + - name: data + emptyDir: {} + {{- end }} + {{- if .Values.redis.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + {{- if .Values.redis.persistence.storageClass }} + storageClassName: {{ .Values.redis.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.redis.persistence.size }} + {{- end }} +{{- end }} diff --git a/deploy/helm/pargar/templates/rustfs.yaml b/deploy/helm/pargar/templates/rustfs.yaml new file mode 100644 index 0000000..a4fee53 --- /dev/null +++ b/deploy/helm/pargar/templates/rustfs.yaml @@ -0,0 +1,101 @@ +{{- if .Values.rustfs.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "pargar.rustfsHost" . }} + labels: + {{- include "pargar.labels" . | nindent 4 }} + app.kubernetes.io/component: rustfs +spec: + type: ClusterIP + ports: + - name: s3 + port: 9000 + targetPort: s3 + selector: + {{- include "pargar.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: rustfs +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "pargar.fullname" . }}-rustfs + labels: + {{- include "pargar.labels" . | nindent 4 }} + app.kubernetes.io/component: rustfs +spec: + serviceName: {{ include "pargar.rustfsHost" . }} + replicas: 1 + selector: + matchLabels: + {{- include "pargar.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: rustfs + template: + metadata: + labels: + {{- include "pargar.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: rustfs + spec: + containers: + - name: rustfs + image: {{ .Values.rustfs.image }} + imagePullPolicy: IfNotPresent + args: ["/data"] + ports: + - name: s3 + containerPort: 9000 + env: + - name: RUSTFS_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ include "pargar.rustfsSecretName" . }} + key: access-key + - name: RUSTFS_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ include "pargar.rustfsSecretName" . }} + key: secret-key + - name: RUSTFS_ADDRESS + value: ":9000" + - name: RUSTFS_CONSOLE_ADDRESS + value: ":9001" + - name: RUSTFS_CONSOLE_ENABLE + value: {{ .Values.rustfs.consoleEnable | ternary "true" "false" | quote }} + readinessProbe: + httpGet: + path: /health + port: s3 + initialDelaySeconds: 10 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /health + port: s3 + initialDelaySeconds: 20 + periodSeconds: 10 + {{- with .Values.rustfs.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: data + mountPath: /data + {{- if not .Values.rustfs.persistence.enabled }} + volumes: + - name: data + emptyDir: {} + {{- end }} + {{- if .Values.rustfs.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + {{- if .Values.rustfs.persistence.storageClass }} + storageClassName: {{ .Values.rustfs.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.rustfs.persistence.size }} + {{- end }} +{{- end }} diff --git a/deploy/helm/pargar/templates/secret-app.yaml b/deploy/helm/pargar/templates/secret-app.yaml new file mode 100644 index 0000000..b140a10 --- /dev/null +++ b/deploy/helm/pargar/templates/secret-app.yaml @@ -0,0 +1,56 @@ +{{- if and .Values.backend.enabled (not .Values.backend.existingSecret) }} +{{- $name := include "pargar.appSecretName" . }} +{{- $existing := lookup "v1" "Secret" .Release.Namespace $name }} +{{- $jwt := "" }} +{{- $admin := "" }} +{{- $metrics := .Values.backend.secrets.metricsToken | default "" }} +{{- if and $existing $existing.data }} + {{- /* Reuse existing secrets so upgrades do not invalidate sessions / bootstrap. */}} + {{- if index $existing.data "jwt-secret" }} + {{- $jwt = index $existing.data "jwt-secret" | b64dec }} + {{- end }} + {{- if index $existing.data "admin-password" }} + {{- $admin = index $existing.data "admin-password" | b64dec }} + {{- end }} + {{- if and (not $metrics) (index $existing.data "metrics-token") }} + {{- $metrics = index $existing.data "metrics-token" | b64dec }} + {{- end }} +{{- end }} +{{- if not $jwt }} + {{- if and .Values.backend.secrets.jwtSecret (not (hasPrefix "change-me" .Values.backend.secrets.jwtSecret)) (ne .Values.backend.secrets.jwtSecret "") }} + {{- $jwt = .Values.backend.secrets.jwtSecret }} + {{- else }} + {{- $jwt = randAlphaNum 48 }} + {{- end }} +{{- end }} +{{- if not $admin }} + {{- if and .Values.backend.secrets.adminPassword (not (hasPrefix "change-me" .Values.backend.secrets.adminPassword)) (ne .Values.backend.secrets.adminPassword "") }} + {{- $admin = .Values.backend.secrets.adminPassword }} + {{- else }} + {{- $admin = randAlphaNum 24 }} + {{- end }} +{{- end }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $name }} + labels: + {{- include "pargar.labels" . | nindent 4 }} + annotations: + pargar.io/secret-source: generated-or-values +type: Opaque +stringData: + jwt-secret: {{ $jwt | quote }} + admin-password: {{ $admin | quote }} + metrics-token: {{ $metrics | quote }} + {{- if not .Values.postgres.enabled }} + database-url: {{ required "external.databaseUrl required when postgres.enabled=false and existingSecret is empty" .Values.external.databaseUrl | quote }} + {{- end }} + {{- if not .Values.redis.enabled }} + redis-url: {{ required "external.redisUrl required when redis.enabled=false and existingSecret is empty" .Values.external.redisUrl | quote }} + {{- end }} + {{- if not .Values.rustfs.enabled }} + storage-access-key: {{ required "external.storageAccessKey required when rustfs.enabled=false and existingSecret is empty" .Values.external.storageAccessKey | quote }} + storage-secret-key: {{ required "external.storageSecretKey required when rustfs.enabled=false and existingSecret is empty" .Values.external.storageSecretKey | quote }} + {{- end }} +{{- end }} diff --git a/deploy/helm/pargar/templates/secret-data.yaml b/deploy/helm/pargar/templates/secret-data.yaml new file mode 100644 index 0000000..9a61258 --- /dev/null +++ b/deploy/helm/pargar/templates/secret-data.yaml @@ -0,0 +1,71 @@ +{{- if and .Values.postgres.enabled (not .Values.postgres.existingSecret) }} +{{- $pgName := include "pargar.postgresSecretName" . }} +{{- $pgExisting := lookup "v1" "Secret" .Release.Namespace $pgName }} +{{- $pgPass := "" }} +{{- if and $pgExisting $pgExisting.data (index $pgExisting.data "postgres-password") }} + {{- /* Never rotate an existing DB password — Postgres data dir is bound to it. */}} + {{- $pgPass = index $pgExisting.data "postgres-password" | b64dec }} +{{- else if and .Values.postgres.auth.password (not (hasPrefix "change-me" .Values.postgres.auth.password)) (ne .Values.postgres.auth.password "") }} + {{- $pgPass = .Values.postgres.auth.password }} +{{- else }} + {{- $pgPass = randAlphaNum 32 }} +{{- end }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $pgName }} + labels: + {{- include "pargar.labels" . | nindent 4 }} +type: Opaque +stringData: + postgres-password: {{ $pgPass | quote }} +--- +{{- end }} +{{- if and .Values.redis.enabled (not .Values.redis.existingSecret) }} +{{- $rdName := include "pargar.redisSecretName" . }} +{{- $rdExisting := lookup "v1" "Secret" .Release.Namespace $rdName }} +{{- $rdPass := "" }} +{{- if and $rdExisting $rdExisting.data (index $rdExisting.data "redis-password") }} + {{- $rdPass = index $rdExisting.data "redis-password" | b64dec }} +{{- else if and .Values.redis.auth.password (not (hasPrefix "change-me" .Values.redis.auth.password)) (ne .Values.redis.auth.password "") }} + {{- $rdPass = .Values.redis.auth.password }} +{{- else }} + {{- $rdPass = randAlphaNum 32 }} +{{- end }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $rdName }} + labels: + {{- include "pargar.labels" . | nindent 4 }} +type: Opaque +stringData: + redis-password: {{ $rdPass | quote }} +--- +{{- end }} +{{- if and .Values.rustfs.enabled (not .Values.rustfs.existingSecret) }} +{{- $rsName := include "pargar.rustfsSecretName" . }} +{{- $rsExisting := lookup "v1" "Secret" .Release.Namespace $rsName }} +{{- $access := .Values.rustfs.auth.accessKey | default "pargar" }} +{{- $secret := "" }} +{{- if and $rsExisting $rsExisting.data (index $rsExisting.data "secret-key") }} + {{- $secret = index $rsExisting.data "secret-key" | b64dec }} + {{- if index $rsExisting.data "access-key" }} + {{- $access = index $rsExisting.data "access-key" | b64dec }} + {{- end }} +{{- else if and .Values.rustfs.auth.secretKey (not (hasPrefix "change-me" .Values.rustfs.auth.secretKey)) (ne .Values.rustfs.auth.secretKey "") }} + {{- $secret = .Values.rustfs.auth.secretKey }} +{{- else }} + {{- $secret = randAlphaNum 40 }} +{{- end }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $rsName }} + labels: + {{- include "pargar.labels" . | nindent 4 }} +type: Opaque +stringData: + access-key: {{ $access | quote }} + secret-key: {{ $secret | quote }} +{{- end }} diff --git a/deploy/helm/pargar/templates/serviceaccount.yaml b/deploy/helm/pargar/templates/serviceaccount.yaml new file mode 100644 index 0000000..41b04ec --- /dev/null +++ b/deploy/helm/pargar/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "pargar.serviceAccountName" . }} + labels: + {{- include "pargar.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/deploy/helm/pargar/templates/tls-secret.yaml b/deploy/helm/pargar/templates/tls-secret.yaml new file mode 100644 index 0000000..35bd8af --- /dev/null +++ b/deploy/helm/pargar/templates/tls-secret.yaml @@ -0,0 +1,21 @@ +{{- if and .Values.ingress.enabled .Values.ingress.tls.enabled (not .Values.ingress.tls.existingSecret) (not .Values.certManager.enabled) }} +{{- $secretName := include "pargar.tlsSecretName" . }} +{{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName }} +{{- $cn := .Values.global.domain }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $secretName }} + labels: + {{- include "pargar.labels" . | nindent 4 }} +type: kubernetes.tls +data: + {{- if $existing }} + tls.crt: {{ index $existing.data "tls.crt" }} + tls.key: {{ index $existing.data "tls.key" }} + {{- else }} + {{- $cert := genSelfSignedCert $cn nil (list $cn) 3650 }} + tls.crt: {{ $cert.Cert | b64enc }} + tls.key: {{ $cert.Key | b64enc }} + {{- end }} +{{- end }} diff --git a/deploy/helm/pargar/values-k3s.yaml b/deploy/helm/pargar/values-k3s.yaml new file mode 100644 index 0000000..8b39133 --- /dev/null +++ b/deploy/helm/pargar/values-k3s.yaml @@ -0,0 +1,55 @@ +# Local k3s profile — self-signed TLS, in-cluster data, single replicas. +# helm upgrade --install pargar ./deploy/helm/pargar -n pargar --create-namespace -f deploy/helm/pargar/values-k3s.yaml +# +# After install, add to /etc/hosts: pargar.local + +global: + domain: pargar.local + publicUrl: https://pargar.local + frontendOrigins: "https://pargar.local" + +backend: + replicaCount: 1 + image: + repository: pargar-backend + tag: local + pullPolicy: IfNotPresent + env: + seedCurriculum: "true" + +frontend: + replicaCount: 1 + image: + repository: pargar-frontend + tag: local + pullPolicy: IfNotPresent + +postgres: + enabled: true + persistence: + size: 5Gi + +redis: + enabled: true + persistence: + size: 1Gi + +rustfs: + enabled: true + persistence: + size: 10Gi + consoleEnable: false + +backup: + enabled: true + schedule: "0 3 * * *" + retentionDays: 7 + persistence: + size: 2Gi + +ingress: + enabled: true + useIngressRoute: true + tls: + enabled: true + existingSecret: "" # chart generates self-signed for pargar.local diff --git a/deploy/helm/pargar/values-prod.yaml b/deploy/helm/pargar/values-prod.yaml new file mode 100644 index 0000000..7bddfe5 --- /dev/null +++ b/deploy/helm/pargar/values-prod.yaml @@ -0,0 +1,103 @@ +# Production profile — external data plane, registry images, HPA/PDB/NetworkPolicy, cert-manager. +# helm upgrade --install pargar ./deploy/helm/pargar -n pargar --create-namespace \ +# -f deploy/helm/pargar/values-prod.yaml +# +# Prerequisites: +# - images pushed to ghcr.io/pargar/{backend,frontend}: +# - Secret pargar-app (jwt-secret, admin-password, metrics-token, database-url, redis-url, +# storage-access-key, storage-secret-key) +# - cert-manager ClusterIssuer letsencrypt-prod +# - imagePullSecrets if the registry is private + +global: + domain: app.example.com + publicUrl: https://app.example.com + frontendOrigins: "https://app.example.com" + imageRegistry: ghcr.io/pargar + +imagePullSecrets: + - name: ghcr-pull + +backend: + replicaCount: 2 + image: + repository: backend + tag: "0.1.0" + pullPolicy: IfNotPresent + existingSecret: pargar-app + env: + devMode: "false" + seedCurriculum: "false" + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: "2" + memory: 1Gi + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 8 + targetCPUUtilizationPercentage: 70 + targetMemoryUtilizationPercentage: 80 + podDisruptionBudget: + enabled: true + minAvailable: 1 + +frontend: + replicaCount: 2 + image: + repository: frontend + tag: "0.1.0" + pullPolicy: IfNotPresent + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: "2" + memory: 1Gi + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 8 + targetCPUUtilizationPercentage: 70 + targetMemoryUtilizationPercentage: 80 + podDisruptionBudget: + enabled: true + minAvailable: 1 + +# Prefer managed Postgres / Redis / object storage in real prod. +postgres: + enabled: false +redis: + enabled: false +rustfs: + enabled: false + +external: + databaseUrl: "" # prefer secret key database-url on pargar-app + redisUrl: "" + storageEndpoint: "https://s3.example.com" + +ingress: + enabled: true + useIngressRoute: true + tls: + enabled: true + existingSecret: "" # Certificate CR owns the secret below + clusterIssuer: letsencrypt-prod + +certManager: + enabled: true + issuerRef: + name: letsencrypt-prod + kind: ClusterIssuer + +networkPolicy: + enabled: true + ingressNamespaces: + - kube-system + ingressPodLabels: + app.kubernetes.io/name: traefik diff --git a/deploy/helm/pargar/values.yaml b/deploy/helm/pargar/values.yaml new file mode 100644 index 0000000..fb300a9 --- /dev/null +++ b/deploy/helm/pargar/values.yaml @@ -0,0 +1,229 @@ +# Default values — safe for local k3s. Override with values-k3s.yaml / values-prod.yaml. + +nameOverride: "" +fullnameOverride: "" + +global: + # Public hostname (Ingress / TLS / PUBLIC_URL) + domain: pargar.local + # Full public origin browsers use (must match Ingress TLS host) + publicUrl: https://pargar.local + frontendOrigins: "https://pargar.local" + # Docker/k8s pod+service CIDRs — Traefik peer is trusted for X-Forwarded-* + trustedProxyCIDRs: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.0/8,::1/128" + # Optional registry prefix, e.g. ghcr.io/pargar → images become ghcr.io/pargar/: + imageRegistry: "" + +imagePullSecrets: [] + +backend: + enabled: true + replicaCount: 1 + image: + repository: pargar-backend + tag: local + pullPolicy: IfNotPresent + service: + port: 8080 + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: "1" + memory: 512Mi + autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 6 + targetCPUUtilizationPercentage: 70 + targetMemoryUtilizationPercentage: 80 + podDisruptionBudget: + enabled: false + minAvailable: 1 + env: + devMode: "false" + seedCurriculum: "false" + donationEnabled: "true" + # Offline payment only — leave off until ops process is ready; enable in admin settings. + physicalCertEnabled: "false" + physicalCertPriceIrr: "2500000" + physicalCertWindowDays: "30" + otelEndpoint: "" + otelServiceName: pargar-api + # Created only when admin user is missing (see seed.ensureBootstrapAdmin) + admin: + name: Admin + username: admin + email: admin@example.com + # Prefer existingSecret in production. When empty, chart generate-once (lookup + rand). + existingSecret: "" + # Optional overrides; leave empty to auto-generate strong values (placeholders like change-me-* are ignored). + secrets: + jwtSecret: "" + adminPassword: "" + metricsToken: "" + +frontend: + enabled: true + replicaCount: 1 + image: + repository: pargar-frontend + tag: local + pullPolicy: IfNotPresent + service: + port: 3000 + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: "1" + memory: 512Mi + autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 6 + targetCPUUtilizationPercentage: 70 + targetMemoryUtilizationPercentage: 80 + podDisruptionBudget: + enabled: false + minAvailable: 1 + +# In-cluster data plane (k3s / small clusters). Disable and point to managed services in prod. +postgres: + enabled: true + image: postgres:16.4-alpine + persistence: + enabled: true + size: 10Gi + storageClass: "" + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + memory: 512Mi + auth: + username: pargar + database: pargar + # Empty → chart generate-once; placeholders change-me-* are ignored. + password: "" + existingSecret: "" + +redis: + enabled: true + image: redis@sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499 + persistence: + enabled: true + size: 2Gi + storageClass: "" + resources: + requests: + cpu: 25m + memory: 64Mi + limits: + memory: 256Mi + auth: + password: "" + existingSecret: "" + +rustfs: + enabled: true + image: rustfs/rustfs@sha256:1803faef57627e2d9c2e7d89d655d712ddded5389040054987163043fecb6a3c + persistence: + enabled: true + size: 20Gi + storageClass: "" + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + memory: 512Mi + auth: + accessKey: "pargar-rustfs" + secretKey: "" + bucket: pargar + region: us-east-1 + consoleEnable: false + existingSecret: "" + +# Postgres dump CronJob (in-cluster postgres only). +backup: + enabled: false + schedule: "0 3 * * *" + retentionDays: 7 + persistence: + size: 5Gi + storageClass: "" + image: postgres:16.4-alpine + +# External endpoints when in-cluster postgres/redis/rustfs are disabled. +# Prefer putting these in backend.existingSecret in production. +external: + databaseUrl: "" + redisUrl: "" + storageEndpoint: "" + storageAccessKey: "" + storageSecretKey: "" + +ingress: + enabled: true + # k3s ships Traefik; use IngressRoute CRDs (recommended) or standard Ingress + className: traefik + # Use Traefik IngressRoute + Middleware CRDs (true) vs networking.k8s.io/Ingress (false) + useIngressRoute: true + annotations: {} + tls: + enabled: true + # When empty and certManager.enabled=false, chart creates a self-signed Secret for k3s/dev. + # Prefer certManager.enabled=true in prod, or set existingSecret to a pre-created TLS secret. + existingSecret: "" + # cert-manager ClusterIssuer name (annotation helper when useIngressRoute=false) + clusterIssuer: "" + +# cert-manager Certificate CR (writes TLS secret used by IngressRoute) +certManager: + enabled: false + duration: 2160h + renewBefore: 360h + issuerRef: + name: letsencrypt-prod + kind: ClusterIssuer + group: "" + extraDnsNames: [] + +# Restrict pod traffic (off for local k3s; on in values-prod.yaml) +networkPolicy: + enabled: false + # Namespaces whose pods may reach frontend/backend (Traefik) + ingressNamespaces: + - kube-system + ingressPodLabels: + app.kubernetes.io/name: traefik + +middlewares: + securityHeaders: true + compress: true + redirectHttps: true + +serviceAccount: + create: true + name: "" + annotations: {} + +podSecurityContext: + fsGroup: 10001 + +securityContext: + runAsNonRoot: true + runAsUser: 10001 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: ["ALL"] + +nodeSelector: {} +tolerations: [] +affinity: {}