{
  "topic": "helm",
  "path": [
    "helm"
  ],
  "title": "helm — Helm chart for Kubernetes deployment",
  "synopsis": "The chart at `deploy/helm/cyoda` deploys cyoda-go on Kubernetes as a StatefulSet backed by an external PostgreSQL database. The chart requires Kubernetes `>=1.31.0`. Chart version: `0.1.0`. App version: `0.1.0` (synchronized to the binary by the `bump-chart-appversion.yml` CI workflow).",
  "body": "# helm\n\n## NAME\n\nhelm — the `deploy/helm/cyoda` Helm chart: values, Kubernetes objects, secrets provisioning, and install/upgrade commands.\n\n## SYNOPSIS\n\n```\nhelm install cyoda ./deploy/helm/cyoda \\\n  --set postgres.existingSecret=cyoda-pg \\\n  --set jwt.existingSecret=cyoda-jwt\n```\n\n## DESCRIPTION\n\nThe chart at `deploy/helm/cyoda` deploys cyoda-go on Kubernetes as a StatefulSet backed by an external PostgreSQL database. The chart requires Kubernetes `>=1.31.0`. Chart version: `0.1.0`. App version: `0.1.0` (synchronized to the binary by the `bump-chart-appversion.yml` CI workflow).\n\nThe chart is not published to a Helm repository. Install directly from a local checkout or from the GitHub repository source tree. The canonical install form is `helm install cyoda ./deploy/helm/cyoda`.\n\nCyoda-go pods are stateless: all persistent state is in PostgreSQL. The chart renders a StatefulSet (not a Deployment) to give each pod a stable DNS identity for gossip peer discovery. Pod management policy is `Parallel` — pods start simultaneously rather than sequentially. Cluster mode is always enabled at the chart level; at `replicas=1` the binary runs as a cluster of one.\n\nCredentials (Postgres DSN, JWT signing key, HMAC secret, metrics bearer token, optional bootstrap client secret) are never stored in the ConfigMap. They are mounted via projected Secret volumes and read by the binary through `CYODA_*_FILE` env vars.\n\n## CHART REPOSITORY\n\nThe chart is not yet published to an OCI registry or a tgz repository. Install from a local path:\n\n```\nhelm install cyoda ./deploy/helm/cyoda [--set ...]\n```\n\nFor a GitOps workflow, package the chart manually:\n\n```\nhelm package ./deploy/helm/cyoda\nhelm install cyoda cyoda-0.1.0.tgz [--set ...]\n```\n\n## VALUES\n\nAll top-level keys in `deploy/helm/cyoda/values.yaml`, with type, default, and purpose.\n\n**`replicas`** — integer — default `1`\nNumber of cyoda pods. Scale up for multi-node cluster. At `replicas=1` the binary runs as a \"cluster of one\". When `autoscaling.enabled=true`, the HPA owns the replica count and the `replicas` value becomes the initial scale only.\n\n**`logLevel`** — string — default `info`\nLog level. Accepted values: `debug`, `info`, `warn`, `error`. Written to the ConfigMap as `CYODA_LOG_LEVEL`.\n\n**`image.repository`** — string — default `ghcr.io/cyoda-platform/cyoda`\nContainer image repository.\n\n**`image.tag`** — string — default `\"\"` (resolved to `.Chart.AppVersion`)\nImage tag. When empty, the chart uses `.Chart.AppVersion`.\n\n**`image.pullPolicy`** — string — default `IfNotPresent`\nImage pull policy. Accepted values: `Always`, `IfNotPresent`, `Never`.\n\n**`imagePullSecrets`** — list — default `[]`\nList of `{name: <secretName>}` entries for private registries. Example: `[{name: ghcr-pull-secret}]`.\n\n**`resources.requests.cpu`** — string — default `100m`\nCPU request for cyoda containers.\n\n**`resources.requests.memory`** — string — default `256Mi`\nMemory request for cyoda containers.\n\n**`resources.limits.cpu`** — string — default `1000m`\nCPU limit for cyoda containers.\n\n**`resources.limits.memory`** — string — default `512Mi`\nMemory limit for cyoda containers.\n\n**`postgres.existingSecret`** — string — default `\"\"` — **REQUIRED**\nName of the Kubernetes Secret containing the Postgres DSN. The Secret must exist before install. The DSN value must be a full connection string: `postgres://user:pass@host:5432/db?sslmode=require`.\n\n**`postgres.existingSecretKey`** — string — default `dsn`\nKey within `postgres.existingSecret` whose value is the Postgres DSN.\n\n**`jwt.existingSecret`** — string — default `\"\"` — **REQUIRED**\nName of the Kubernetes Secret containing the PEM-encoded RSA private key for JWT signing.\n\n**`jwt.existingSecretKey`** — string — default `signing-key.pem`\nKey within `jwt.existingSecret` whose value is the PEM-encoded RSA private key.\n\n**`jwt.issuer`** — string — default `cyoda`\nJWT issuer claim. Written to ConfigMap as `CYODA_JWT_ISSUER`.\n\n**`jwt.expirySeconds`** — integer — default `3600`\nJWT token expiry in seconds. Written to ConfigMap as `CYODA_JWT_EXPIRY_SECONDS`.\n\n**`cluster.hmacSecret.existingSecret`** — string — default `\"\"`\nName of an operator-managed Secret containing the HMAC secret. When empty, the chart auto-generates the Secret on first install using `lookup` to detect existing state. GitOps controllers (Argo CD) must set this to a pre-created Secret; the chart fails with an error if rendered without live cluster access (e.g. `helm template`, `--dry-run`) and no `existingSecret` is provided.\n\n**`cluster.hmacSecret.existingSecretKey`** — string — default `secret`\nKey within the HMAC Secret whose value is the hex-encoded HMAC secret. The binary reads it via `CYODA_HMAC_SECRET_FILE` and decodes hex to raw bytes.\n\n**`bootstrap.clientId`** — string — default `\"\"`\nBootstrap M2M client ID. Bootstrap provisioning is opt-in. When empty, no bootstrap Secret is rendered and no bootstrap credential is set. When non-empty, the chart provisions the bootstrap M2M client. The binary's coupled predicate (both ID and Secret set, or both empty) applies.\n\n**`bootstrap.clientSecret.existingSecret`** — string — default `\"\"`\nName of an operator-managed Secret containing the bootstrap client secret. When `bootstrap.clientId` is non-empty and this is empty, the chart auto-generates the Secret. GitOps safety guard applies (same pattern as HMAC).\n\n**`bootstrap.clientSecret.existingSecretKey`** — string — default `secret`\nKey within the bootstrap client Secret.\n\n**`bootstrap.tenantId`** — string — default `default-tenant`\nBootstrap tenant ID. Written to ConfigMap as `CYODA_BOOTSTRAP_TENANT_ID`.\n\n**`bootstrap.userId`** — string — default `admin`\nBootstrap user ID. Written to ConfigMap as `CYODA_BOOTSTRAP_USER_ID`.\n\n**`bootstrap.roles`** — string — default `ROLE_ADMIN,ROLE_M2M`\nComma-separated roles for the bootstrap client. Written to ConfigMap as `CYODA_BOOTSTRAP_ROLES`.\n\n**`extraEnv`** — list — default `[]`\nArbitrary additional env vars injected into the StatefulSet container. Each entry is `{name, value}` or `{name, valueFrom}`. Use for OTel configuration (`CYODA_OTEL_ENABLED`, `OTEL_EXPORTER_OTLP_ENDPOINT`, etc.), feature flags, and tuning knobs. Do not set `CYODA_*_FILE` credential vars or the four chart-managed credential env vars here — the chart sets those and Kubernetes rejects duplicates.\n\n**`service.type`** — string — default `ClusterIP`\nKubernetes Service type for the main Service (ports 8080, 9090, 9091). Accepted values: `ClusterIP`, `NodePort`, `LoadBalancer`.\n\n**`gateway.enabled`** — boolean — default `true`\nEnable Gateway API routing. Renders `HTTPRoute` (port 8080) and `GRPCRoute` (port 9090). Mutually exclusive with `ingress.enabled`; both enabled triggers a `fail`. When `true`, `gateway.parentRefs` MUST be set to a non-empty list of operator-provided Gateway references; an empty list causes install to fail.\n\n**`gateway.parentRefs`** — list — default `[]` — **REQUIRED when `gateway.enabled=true`**\nList of Gateway API parent references (operator-provided Gateway). The chart does not render the Gateway itself.\n\n**`gateway.http.hostnames`** — list — default `[]`\nHTTP hostnames for the `HTTPRoute`. When empty, the route matches all hostnames.\n\n**`gateway.grpc.hostnames`** — list — default `[]`\ngRPC hostnames for the `GRPCRoute`. When empty, the route matches all hostnames.\n\n**`ingress.enabled`** — boolean — default `false`\nEnable Ingress routing (transitional; `gateway.enabled=true` is preferred). Mutually exclusive with `gateway.enabled`.\n\n**`ingress.className`** — string — default `\"\"`\n`ingressClassName` for the Ingress objects.\n\n**`ingress.http.host`** — string — default `\"\"`\nHostname for the HTTP Ingress rule.\n\n**`ingress.http.paths`** — list — default `[{path: /, pathType: Prefix}]`\nPath rules for the HTTP Ingress.\n\n**`ingress.http.annotations`** — map — default `{}`\nAnnotations on the HTTP Ingress object.\n\n**`ingress.http.tls`** — list — default `[]`\nTLS configuration for the HTTP Ingress.\n\n**`ingress.grpc.host`** — string — default `\"\"`\nHostname for the gRPC Ingress rule.\n\n**`ingress.grpc.paths`** — list — default `[{path: /, pathType: Prefix}]`\nPath rules for the gRPC Ingress.\n\n**`ingress.grpc.annotations`** — map — default `{nginx.ingress.kubernetes.io/backend-protocol: GRPC}`\nAnnotations on the gRPC Ingress object.\n\n**`ingress.grpc.tls`** — list — default `[]`\nTLS configuration for the gRPC Ingress.\n\n**`monitoring.metricsBearer.existingSecret`** — string — default `\"\"`\nName of an operator-managed Secret containing the static bearer token for `GET /metrics` authentication. When empty, the chart auto-generates the Secret. GitOps safety guard applies.\n\n**`monitoring.metricsBearer.existingSecretKey`** — string — default `bearer`\nKey within the metrics bearer Secret.\n\n**`monitoring.serviceMonitor.enabled`** — boolean — default `false`\nRender a `ServiceMonitor` (Prometheus Operator CRD). When enabled, Prometheus Operator scrapes `GET :9091/metrics` using the bearer token from `monitoring.metricsBearer` Secret.\n\n**`monitoring.serviceMonitor.interval`** — string — default `30s`\nPrometheus scrape interval.\n\n**`monitoring.serviceMonitor.labels`** — map — default `{}`\nAdditional labels on the `ServiceMonitor` (used by Prometheus Operator's `serviceMonitorSelector`).\n\n**`networkPolicy.enabled`** — boolean — default `true`\nRender a `NetworkPolicy`. Restricts port 9091 (admin/metrics) ingress to namespaces declared in `metricsFromNamespaces`. Restricts port 7946 (gossip) ingress to chart-managed pods. Ports 8080 and 9090 are unrestricted at the NetworkPolicy layer (boundary is the Gateway/Ingress). Requires a CNI that enforces NetworkPolicy (Calico, Cilium, Weave). kindnet and some managed CNIs do not enforce NetworkPolicy — set `enabled=false` on those clusters.\n\n**`networkPolicy.metricsFromNamespaces`** — list — default `[{matchLabels: {kubernetes.io/metadata.name: monitoring}}]`\nNamespace selectors permitted to reach port 9091. Must be non-empty when `networkPolicy.enabled=true`.\n\n**`autoscaling.enabled`** — boolean — default `false`\nRender a `HorizontalPodAutoscaler` targeting the StatefulSet. When enabled, the HPA owns the replica count; the static `replicas` value becomes the initial count only.\n\n**`autoscaling.minReplicas`** — integer — default `1`\nHPA minimum replica count.\n\n**`autoscaling.maxReplicas`** — integer — default `3`\nHPA maximum replica count.\n\n**`autoscaling.targetCPUUtilizationPercentage`** — integer — default `80`\nHPA CPU utilization target. Omit or set to `0` to disable CPU-based scaling.\n\n**`autoscaling.targetMemoryUtilizationPercentage`** — integer — not set by default\nHPA memory utilization target. Uncomment in `values.yaml` to enable memory-based scaling.\n\n**`autoscaling.behavior`** — map — default `{}`\nHPA `behavior` block for stabilization windows and scale-up/scale-down policies (`autoscaling/v2` schema).\n\n**`migrate.activeDeadlineSeconds`** — integer — default `600`\n`activeDeadlineSeconds` for the pre-upgrade migration Job.\n\n**`migrate.backoffLimit`** — integer — default `2`\n`backoffLimit` for the migration Job.\n\n**`migrate.resources.requests.cpu`** — string — default `100m`\nCPU request for the migration Job container.\n\n**`migrate.resources.requests.memory`** — string — default `128Mi`\nMemory request for the migration Job container.\n\n**`migrate.resources.limits.cpu`** — string — default `500m`\nCPU limit for the migration Job container.\n\n**`migrate.resources.limits.memory`** — string — default `256Mi`\nMemory limit for the migration Job container.\n\n**`podDisruptionBudget.enabled`** — boolean — default `true`\nRender a `PodDisruptionBudget`. Rendered only when `replicas > 1` or `autoscaling.enabled=true` with `maxReplicas > 1`.\n\n**`podDisruptionBudget.minAvailable`** — integer — default `1`\nMinimum available pods during voluntary disruptions.\n\n**`serviceAccount.create`** — boolean — default `true`\nCreate a dedicated `ServiceAccount`. The ServiceAccount is a Helm pre-install/pre-upgrade hook with weight `-10` so it exists before the migration Job (weight `0`).\n\n**`serviceAccount.name`** — string — default `\"\"` (resolved to the chart fullname)\nName of the ServiceAccount. When empty, the chart fullname is used.\n\n**`serviceAccount.annotations`** — map — default `{}`\nAnnotations on the ServiceAccount (e.g. for IRSA/Workload Identity).\n\n**`podAnnotations`** — map — default `{}`\nAnnotations added to all pods in the StatefulSet.\n\n**`podLabels`** — map — default `{}`\nLabels added to all pods in the StatefulSet.\n\n**`nodeSelector`** — map — default `{}`\nNode selector for the StatefulSet pod spec.\n\n**`tolerations`** — list — default `[]`\nTolerations for the StatefulSet pod spec.\n\n**`affinity`** — map — default `{}`\nAffinity rules for the StatefulSet pod spec.\n\n**`nameOverride`** — string — default `\"\"`\nOverride the chart name component of generated resource names.\n\n**`fullnameOverride`** — string — default `\"\"`\nOverride the full generated resource name prefix.\n\n## CRDS / OBJECTS\n\nThe chart renders the following Kubernetes objects. Conditional objects note their enabling value.\n\n**Always rendered:**\n\n- `StatefulSet` (`apps/v1`) — the cyoda workload. `podManagementPolicy: Parallel`. `updateStrategy: RollingUpdate`. No `volumeClaimTemplates` (cyoda is stateless vs. PostgreSQL). Mounts a projected Secret volume at `/etc/cyoda/secrets` (mode `0400`) and an `emptyDir` at `/tmp`. Runs as UID/GID 65532, non-root, `readOnlyRootFilesystem: true`, `allowPrivilegeEscalation: false`, all capabilities dropped, `seccompProfile: RuntimeDefault`.\n- `Service` (`v1`) — ClusterIP Service exposing ports `8080` (http), `9090` (grpc), `9091` (metrics).\n- `Service` (headless, `v1`) — `clusterIP: None`, `publishNotReadyAddresses: true`. Exposes port `7946` TCP and UDP for gossip (memberlist). Used as the `serviceName` for the StatefulSet.\n- `ConfigMap` (`v1`) — non-sensitive env vars loaded via `envFrom`. Contains: `CYODA_HTTP_PORT`, `CYODA_GRPC_PORT`, `CYODA_ADMIN_PORT`, `CYODA_ADMIN_BIND_ADDRESS`, `CYODA_METRICS_REQUIRE_AUTH`, `CYODA_IAM_MODE`, `CYODA_REQUIRE_JWT`, `CYODA_STORAGE_BACKEND`, `CYODA_POSTGRES_AUTO_MIGRATE`, `CYODA_CLUSTER_ENABLED`, `CYODA_SEED_NODES`, `CYODA_LOG_LEVEL`, `CYODA_JWT_ISSUER`, `CYODA_JWT_EXPIRY_SECONDS`, `CYODA_BOOTSTRAP_TENANT_ID`, `CYODA_BOOTSTRAP_USER_ID`, `CYODA_BOOTSTRAP_ROLES`, and `CYODA_BOOTSTRAP_CLIENT_ID` (when `bootstrap.clientId` is set). Is a Helm pre-install/pre-upgrade hook with weight `-10`.\n- `Job` (`batch/v1`) — migration Job running `cyoda migrate`. Helm pre-install/pre-upgrade hook (weight `0`, delete policy `before-hook-creation,hook-succeeded`). Mounts only the Postgres DSN Secret (principle of least privilege). Uses `restartPolicy: Never`.\n- `NetworkPolicy` (`networking.k8s.io/v1`) — rendered when `networkPolicy.enabled=true`. (Conditional.)\n- `Secret` (HMAC) — rendered when `cluster.hmacSecret.existingSecret=\"\"`. Manages the hex-encoded HMAC secret. Auto-generates on first install; reuses on re-render via `lookup`. GitOps safety guard: fails if rendered without live cluster access. (Conditional.)\n- `Secret` (metrics bearer) — rendered when `monitoring.metricsBearer.existingSecret=\"\"`. Manages the static bearer token for `/metrics`. Auto-generates (48-char alphanumeric). GitOps safety guard applies. (Conditional.)\n\n**Conditional:**\n\n- `ServiceAccount` (`v1`) — rendered when `serviceAccount.create=true`. Helm hook weight `-10`.\n- `Secret` (bootstrap) — rendered when `bootstrap.clientId != \"\"` and `bootstrap.clientSecret.existingSecret=\"\"`. Auto-generates (48-char alphanumeric). GitOps safety guard applies.\n- `HorizontalPodAutoscaler` (`autoscaling/v2`) — rendered when `autoscaling.enabled=true`.\n- `PodDisruptionBudget` (`policy/v1`) — rendered when `podDisruptionBudget.enabled=true` and `replicas > 1` or `autoscaling.maxReplicas > 1`.\n- `HTTPRoute` (`gateway.networking.k8s.io/v1`) — rendered when `gateway.enabled=true`. Routes port 8080.\n- `GRPCRoute` (`gateway.networking.k8s.io/v1`) — rendered when `gateway.enabled=true`. Routes port 9090.\n- `Ingress` (HTTP, `networking.k8s.io/v1`) — rendered when `ingress.enabled=true`. Mutually exclusive with `gateway.enabled`.\n- `Ingress` (gRPC, `networking.k8s.io/v1`) — rendered when `ingress.enabled=true`.\n- `ServiceMonitor` (`monitoring.coreos.com/v1`) — rendered when `monitoring.serviceMonitor.enabled=true`. References the metrics bearer Secret for scrape authentication.\n\n## GENERATING SECRETS\n\n**JWT signing key** — generate an RSA-2048 private key and load it into a Kubernetes Secret:\n\n```\nopenssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out signing.pem\nkubectl create secret generic cyoda-jwt -n cyoda \\\n  --from-file=signing-key.pem=signing.pem\n```\n\n**HMAC secret** — generate 32 bytes of entropy (64 hex chars) and load into a Kubernetes Secret:\n\n```\nkubectl create secret generic cyoda-hmac -n cyoda \\\n  --from-literal=secret=\"$(openssl rand -hex 32)\"\n```\n\nSee `quickstart` for accepted key formats and format-specific `openssl` commands.\n\n## SECRETS PROVISIONING\n\nThe chart never stores credentials in the ConfigMap. All credentials are mounted via a projected Secret volume at `/etc/cyoda/secrets` and read by the binary through `CYODA_*_FILE` env vars. The file paths and corresponding env vars are set in the StatefulSet env block:\n\n- `CYODA_POSTGRES_URL_FILE=/etc/cyoda/secrets/postgres-dsn` — sourced from `postgres.existingSecret` key `postgres.existingSecretKey`.\n- `CYODA_JWT_SIGNING_KEY_FILE=/etc/cyoda/secrets/jwt-signing-key.pem` — sourced from `jwt.existingSecret` key `jwt.existingSecretKey`.\n- `CYODA_HMAC_SECRET_FILE=/etc/cyoda/secrets/hmac-secret` — sourced from the chart-managed or operator-provided HMAC Secret.\n- `CYODA_METRICS_BEARER_FILE=/etc/cyoda/secrets/metrics-bearer` — sourced from the chart-managed or operator-provided metrics bearer Secret.\n- `CYODA_BOOTSTRAP_CLIENT_SECRET_FILE=/etc/cyoda/secrets/bootstrap-client-secret` — sourced from the chart-managed or operator-provided bootstrap Secret. Mounted only when `bootstrap.clientId` is non-empty.\n\nThe projected volume `defaultMode` is `0400` (owner read-only). The pod `securityContext.fsGroup=65532` ensures mounted Secret files are readable by the non-root container user.\n\nThe migration Job mounts only the Postgres DSN Secret (principle of least privilege). It does not receive JWT, HMAC, metrics bearer, or bootstrap credentials.\n\n**GitOps safety guard (HMAC, metrics bearer, bootstrap secrets):** When `existingSecret` is empty, the chart uses `lookup` to detect whether the Secret already exists. On first install with live cluster access, it generates a random value. On subsequent renders, it reuses the existing value. When `lookup` returns no result (because Helm is run without live cluster access — `helm template`, `--dry-run`, Argo CD, first-time `--create-namespace`), the chart fails with an explicit error message. To avoid this: either pre-create the Secret and set `existingSecret`, use `external-secrets-operator`, or create the namespace first with `kubectl create namespace`.\n\n**CYODA_METRICS_REQUIRE_AUTH:** The ConfigMap always sets `CYODA_METRICS_REQUIRE_AUTH=true` on Helm deployments. This forces the binary's coupled-predicate validator to refuse startup if the metrics bearer Secret is absent or empty, providing a belt-and-braces guard against chart misconfiguration.\n\n## INSTALL / UPGRADE / UNINSTALL\n\n**Install (pre-create secrets, then install):**\n\n```\nkubectl create secret generic cyoda-pg \\\n  --from-literal=dsn=\"postgres://cyoda:secret@pg-host:5432/cyoda?sslmode=require\"\nkubectl create secret generic cyoda-jwt \\\n  --from-literal=signing-key.pem=\"$(cat signing.pem)\"\n\nhelm install cyoda ./deploy/helm/cyoda \\\n  --namespace cyoda \\\n  --create-namespace \\\n  --set postgres.existingSecret=cyoda-pg \\\n  --set jwt.existingSecret=cyoda-jwt \\\n  --set cluster.hmacSecret.existingSecret=cyoda-hmac\n```\n\nNote: `--create-namespace` triggers the GitOps safety guard (namespace does not yet exist when Helm renders). Pre-create the namespace first:\n\n```\nkubectl create namespace cyoda\nkubectl create secret generic cyoda-pg -n cyoda \\\n  --from-literal=dsn=\"postgres://cyoda:secret@pg-host:5432/cyoda?sslmode=require\"\nkubectl create secret generic cyoda-jwt -n cyoda \\\n  --from-literal=signing-key.pem=\"$(cat signing.pem)\"\n\nhelm install cyoda ./deploy/helm/cyoda \\\n  --namespace cyoda \\\n  --set postgres.existingSecret=cyoda-pg \\\n  --set jwt.existingSecret=cyoda-jwt\n```\n\n**Upgrade:**\n\n```\nhelm upgrade --install cyoda ./deploy/helm/cyoda \\\n  --namespace cyoda \\\n  --set postgres.existingSecret=cyoda-pg \\\n  --set jwt.existingSecret=cyoda-jwt \\\n  --reuse-values\n```\n\n**Uninstall:**\n\n```\nhelm uninstall cyoda --namespace cyoda\n```\n\nUninstall does not delete PersistentVolumeClaims, Secrets not managed by the chart, or the namespace. Delete chart-managed Secrets manually if desired:\n\n```\nkubectl delete secret cyoda-hmac cyoda-metrics-bearer -n cyoda\n```\n\n**Dry-run (template rendering without cluster access — HMAC/metrics secret must be pre-created):**\n\n```\nkubectl create secret generic cyoda-hmac -n cyoda --from-literal=secret=placeholder\nkubectl create secret generic cyoda-metrics-bearer -n cyoda --from-literal=bearer=placeholder\nhelm template cyoda ./deploy/helm/cyoda \\\n  --namespace cyoda \\\n  --set postgres.existingSecret=cyoda-pg \\\n  --set jwt.existingSecret=cyoda-jwt \\\n  --set cluster.hmacSecret.existingSecret=cyoda-hmac \\\n  --set monitoring.metricsBearer.existingSecret=cyoda-metrics-bearer\n```\n\n## EXAMPLES\n\n**Minimal install (pre-created postgres + jwt secrets):**\n\n**Bare-cluster install** — `gateway.enabled=false` disables the Gateway API route objects. Use this form on clusters without Gateway API CRDs (kind, minikube, most vanilla clusters). The chart falls back to the built-in `Service` for traffic ingress.\n\n```\nkubectl create namespace cyoda\nkubectl create secret generic cyoda-pg -n cyoda \\\n  --from-literal=dsn=\"postgres://cyoda:pass@db.example.com:5432/cyoda?sslmode=require\"\nkubectl create secret generic cyoda-jwt -n cyoda \\\n  --from-literal=signing-key.pem=\"$(cat signing.pem)\"\n\nhelm install cyoda ./deploy/helm/cyoda \\\n  --namespace cyoda \\\n  --set postgres.existingSecret=cyoda-pg \\\n  --set jwt.existingSecret=cyoda-jwt \\\n  --set gateway.enabled=false\n```\n\n**Multi-replica cluster with HPA:**\n\n```\nhelm install cyoda ./deploy/helm/cyoda \\\n  --namespace cyoda \\\n  --set postgres.existingSecret=cyoda-pg \\\n  --set jwt.existingSecret=cyoda-jwt \\\n  --set replicas=3 \\\n  --set autoscaling.enabled=true \\\n  --set autoscaling.minReplicas=3 \\\n  --set autoscaling.maxReplicas=10\n```\n\n**With OTel tracing (via extraEnv):**\n\n```\nhelm install cyoda ./deploy/helm/cyoda \\\n  --namespace cyoda \\\n  --set postgres.existingSecret=cyoda-pg \\\n  --set jwt.existingSecret=cyoda-jwt \\\n  --set extraEnv[0].name=CYODA_OTEL_ENABLED \\\n  --set extraEnv[0].value=true \\\n  --set extraEnv[1].name=OTEL_EXPORTER_OTLP_ENDPOINT \\\n  --set extraEnv[1].value=http://otel-collector.monitoring.svc.cluster.local:4318 \\\n  --set extraEnv[2].name=OTEL_SERVICE_NAME \\\n  --set extraEnv[2].value=cyoda\n```\n\n**With ServiceMonitor for Prometheus Operator:**\n\n```\nhelm install cyoda ./deploy/helm/cyoda \\\n  --namespace cyoda \\\n  --set postgres.existingSecret=cyoda-pg \\\n  --set jwt.existingSecret=cyoda-jwt \\\n  --set monitoring.serviceMonitor.enabled=true \\\n  --set monitoring.serviceMonitor.labels.release=prometheus\n```\n\n**With bootstrap M2M client:**\n\n```\nkubectl create secret generic cyoda-bootstrap -n cyoda \\\n  --from-literal=secret=\"$(openssl rand -base64 36)\"\n\nhelm install cyoda ./deploy/helm/cyoda \\\n  --namespace cyoda \\\n  --set postgres.existingSecret=cyoda-pg \\\n  --set jwt.existingSecret=cyoda-jwt \\\n  --set bootstrap.clientId=m2m-api-client \\\n  --set bootstrap.clientSecret.existingSecret=cyoda-bootstrap \\\n  --set bootstrap.tenantId=acme \\\n  --set-string 'bootstrap.roles=ROLE_ADMIN\\,ROLE_M2M'\n```\n\nHelm treats commas in `--set` as array separators. Use `--set-string` with an escaped comma (`\\,`) or provide the value via a `values.yaml` file to preserve the single string.\n\n**With Gateway API (requires operator-provided Gateway):**\n\n```\nhelm install cyoda ./deploy/helm/cyoda \\\n  --namespace cyoda \\\n  --set postgres.existingSecret=cyoda-pg \\\n  --set jwt.existingSecret=cyoda-jwt \\\n  --set \"gateway.parentRefs[0].name=prod-gateway\" \\\n  --set \"gateway.parentRefs[0].namespace=gateway-system\" \\\n  --set \"gateway.http.hostnames[0]=api.example.com\" \\\n  --set \"gateway.grpc.hostnames[0]=grpc.example.com\"\n```\n\n## SEE ALSO\n\n- run\n- config\n- config.database\n- config.auth\n- quickstart\n",
  "sections": [
    {
      "name": "NAME",
      "body": "helm — the `deploy/helm/cyoda` Helm chart: values, Kubernetes objects, secrets provisioning, and install/upgrade commands."
    },
    {
      "name": "SYNOPSIS",
      "body": "```\nhelm install cyoda ./deploy/helm/cyoda \\\n  --set postgres.existingSecret=cyoda-pg \\\n  --set jwt.existingSecret=cyoda-jwt\n```"
    },
    {
      "name": "DESCRIPTION",
      "body": "The chart at `deploy/helm/cyoda` deploys cyoda-go on Kubernetes as a StatefulSet backed by an external PostgreSQL database. The chart requires Kubernetes `>=1.31.0`. Chart version: `0.1.0`. App version: `0.1.0` (synchronized to the binary by the `bump-chart-appversion.yml` CI workflow).\n\nThe chart is not published to a Helm repository. Install directly from a local checkout or from the GitHub repository source tree. The canonical install form is `helm install cyoda ./deploy/helm/cyoda`.\n\nCyoda-go pods are stateless: all persistent state is in PostgreSQL. The chart renders a StatefulSet (not a Deployment) to give each pod a stable DNS identity for gossip peer discovery. Pod management policy is `Parallel` — pods start simultaneously rather than sequentially. Cluster mode is always enabled at the chart level; at `replicas=1` the binary runs as a cluster of one.\n\nCredentials (Postgres DSN, JWT signing key, HMAC secret, metrics bearer token, optional bootstrap client secret) are never stored in the ConfigMap. They are mounted via projected Secret volumes and read by the binary through `CYODA_*_FILE` env vars."
    },
    {
      "name": "CHART REPOSITORY",
      "body": "The chart is not yet published to an OCI registry or a tgz repository. Install from a local path:\n\n```\nhelm install cyoda ./deploy/helm/cyoda [--set ...]\n```\n\nFor a GitOps workflow, package the chart manually:\n\n```\nhelm package ./deploy/helm/cyoda\nhelm install cyoda cyoda-0.1.0.tgz [--set ...]\n```"
    },
    {
      "name": "VALUES",
      "body": "All top-level keys in `deploy/helm/cyoda/values.yaml`, with type, default, and purpose.\n\n**`replicas`** — integer — default `1`\nNumber of cyoda pods. Scale up for multi-node cluster. At `replicas=1` the binary runs as a \"cluster of one\". When `autoscaling.enabled=true`, the HPA owns the replica count and the `replicas` value becomes the initial scale only.\n\n**`logLevel`** — string — default `info`\nLog level. Accepted values: `debug`, `info`, `warn`, `error`. Written to the ConfigMap as `CYODA_LOG_LEVEL`.\n\n**`image.repository`** — string — default `ghcr.io/cyoda-platform/cyoda`\nContainer image repository.\n\n**`image.tag`** — string — default `\"\"` (resolved to `.Chart.AppVersion`)\nImage tag. When empty, the chart uses `.Chart.AppVersion`.\n\n**`image.pullPolicy`** — string — default `IfNotPresent`\nImage pull policy. Accepted values: `Always`, `IfNotPresent`, `Never`.\n\n**`imagePullSecrets`** — list — default `[]`\nList of `{name: <secretName>}` entries for private registries. Example: `[{name: ghcr-pull-secret}]`.\n\n**`resources.requests.cpu`** — string — default `100m`\nCPU request for cyoda containers.\n\n**`resources.requests.memory`** — string — default `256Mi`\nMemory request for cyoda containers.\n\n**`resources.limits.cpu`** — string — default `1000m`\nCPU limit for cyoda containers.\n\n**`resources.limits.memory`** — string — default `512Mi`\nMemory limit for cyoda containers.\n\n**`postgres.existingSecret`** — string — default `\"\"` — **REQUIRED**\nName of the Kubernetes Secret containing the Postgres DSN. The Secret must exist before install. The DSN value must be a full connection string: `postgres://user:pass@host:5432/db?sslmode=require`.\n\n**`postgres.existingSecretKey`** — string — default `dsn`\nKey within `postgres.existingSecret` whose value is the Postgres DSN.\n\n**`jwt.existingSecret`** — string — default `\"\"` — **REQUIRED**\nName of the Kubernetes Secret containing the PEM-encoded RSA private key for JWT signing.\n\n**`jwt.existingSecretKey`** — string — default `signing-key.pem`\nKey within `jwt.existingSecret` whose value is the PEM-encoded RSA private key.\n\n**`jwt.issuer`** — string — default `cyoda`\nJWT issuer claim. Written to ConfigMap as `CYODA_JWT_ISSUER`.\n\n**`jwt.expirySeconds`** — integer — default `3600`\nJWT token expiry in seconds. Written to ConfigMap as `CYODA_JWT_EXPIRY_SECONDS`.\n\n**`cluster.hmacSecret.existingSecret`** — string — default `\"\"`\nName of an operator-managed Secret containing the HMAC secret. When empty, the chart auto-generates the Secret on first install using `lookup` to detect existing state. GitOps controllers (Argo CD) must set this to a pre-created Secret; the chart fails with an error if rendered without live cluster access (e.g. `helm template`, `--dry-run`) and no `existingSecret` is provided.\n\n**`cluster.hmacSecret.existingSecretKey`** — string — default `secret`\nKey within the HMAC Secret whose value is the hex-encoded HMAC secret. The binary reads it via `CYODA_HMAC_SECRET_FILE` and decodes hex to raw bytes.\n\n**`bootstrap.clientId`** — string — default `\"\"`\nBootstrap M2M client ID. Bootstrap provisioning is opt-in. When empty, no bootstrap Secret is rendered and no bootstrap credential is set. When non-empty, the chart provisions the bootstrap M2M client. The binary's coupled predicate (both ID and Secret set, or both empty) applies.\n\n**`bootstrap.clientSecret.existingSecret`** — string — default `\"\"`\nName of an operator-managed Secret containing the bootstrap client secret. When `bootstrap.clientId` is non-empty and this is empty, the chart auto-generates the Secret. GitOps safety guard applies (same pattern as HMAC).\n\n**`bootstrap.clientSecret.existingSecretKey`** — string — default `secret`\nKey within the bootstrap client Secret.\n\n**`bootstrap.tenantId`** — string — default `default-tenant`\nBootstrap tenant ID. Written to ConfigMap as `CYODA_BOOTSTRAP_TENANT_ID`.\n\n**`bootstrap.userId`** — string — default `admin`\nBootstrap user ID. Written to ConfigMap as `CYODA_BOOTSTRAP_USER_ID`.\n\n**`bootstrap.roles`** — string — default `ROLE_ADMIN,ROLE_M2M`\nComma-separated roles for the bootstrap client. Written to ConfigMap as `CYODA_BOOTSTRAP_ROLES`.\n\n**`extraEnv`** — list — default `[]`\nArbitrary additional env vars injected into the StatefulSet container. Each entry is `{name, value}` or `{name, valueFrom}`. Use for OTel configuration (`CYODA_OTEL_ENABLED`, `OTEL_EXPORTER_OTLP_ENDPOINT`, etc.), feature flags, and tuning knobs. Do not set `CYODA_*_FILE` credential vars or the four chart-managed credential env vars here — the chart sets those and Kubernetes rejects duplicates.\n\n**`service.type`** — string — default `ClusterIP`\nKubernetes Service type for the main Service (ports 8080, 9090, 9091). Accepted values: `ClusterIP`, `NodePort`, `LoadBalancer`.\n\n**`gateway.enabled`** — boolean — default `true`\nEnable Gateway API routing. Renders `HTTPRoute` (port 8080) and `GRPCRoute` (port 9090). Mutually exclusive with `ingress.enabled`; both enabled triggers a `fail`. When `true`, `gateway.parentRefs` MUST be set to a non-empty list of operator-provided Gateway references; an empty list causes install to fail.\n\n**`gateway.parentRefs`** — list — default `[]` — **REQUIRED when `gateway.enabled=true`**\nList of Gateway API parent references (operator-provided Gateway). The chart does not render the Gateway itself.\n\n**`gateway.http.hostnames`** — list — default `[]`\nHTTP hostnames for the `HTTPRoute`. When empty, the route matches all hostnames.\n\n**`gateway.grpc.hostnames`** — list — default `[]`\ngRPC hostnames for the `GRPCRoute`. When empty, the route matches all hostnames.\n\n**`ingress.enabled`** — boolean — default `false`\nEnable Ingress routing (transitional; `gateway.enabled=true` is preferred). Mutually exclusive with `gateway.enabled`.\n\n**`ingress.className`** — string — default `\"\"`\n`ingressClassName` for the Ingress objects.\n\n**`ingress.http.host`** — string — default `\"\"`\nHostname for the HTTP Ingress rule.\n\n**`ingress.http.paths`** — list — default `[{path: /, pathType: Prefix}]`\nPath rules for the HTTP Ingress.\n\n**`ingress.http.annotations`** — map — default `{}`\nAnnotations on the HTTP Ingress object.\n\n**`ingress.http.tls`** — list — default `[]`\nTLS configuration for the HTTP Ingress.\n\n**`ingress.grpc.host`** — string — default `\"\"`\nHostname for the gRPC Ingress rule.\n\n**`ingress.grpc.paths`** — list — default `[{path: /, pathType: Prefix}]`\nPath rules for the gRPC Ingress.\n\n**`ingress.grpc.annotations`** — map — default `{nginx.ingress.kubernetes.io/backend-protocol: GRPC}`\nAnnotations on the gRPC Ingress object.\n\n**`ingress.grpc.tls`** — list — default `[]`\nTLS configuration for the gRPC Ingress.\n\n**`monitoring.metricsBearer.existingSecret`** — string — default `\"\"`\nName of an operator-managed Secret containing the static bearer token for `GET /metrics` authentication. When empty, the chart auto-generates the Secret. GitOps safety guard applies.\n\n**`monitoring.metricsBearer.existingSecretKey`** — string — default `bearer`\nKey within the metrics bearer Secret.\n\n**`monitoring.serviceMonitor.enabled`** — boolean — default `false`\nRender a `ServiceMonitor` (Prometheus Operator CRD). When enabled, Prometheus Operator scrapes `GET :9091/metrics` using the bearer token from `monitoring.metricsBearer` Secret.\n\n**`monitoring.serviceMonitor.interval`** — string — default `30s`\nPrometheus scrape interval.\n\n**`monitoring.serviceMonitor.labels`** — map — default `{}`\nAdditional labels on the `ServiceMonitor` (used by Prometheus Operator's `serviceMonitorSelector`).\n\n**`networkPolicy.enabled`** — boolean — default `true`\nRender a `NetworkPolicy`. Restricts port 9091 (admin/metrics) ingress to namespaces declared in `metricsFromNamespaces`. Restricts port 7946 (gossip) ingress to chart-managed pods. Ports 8080 and 9090 are unrestricted at the NetworkPolicy layer (boundary is the Gateway/Ingress). Requires a CNI that enforces NetworkPolicy (Calico, Cilium, Weave). kindnet and some managed CNIs do not enforce NetworkPolicy — set `enabled=false` on those clusters.\n\n**`networkPolicy.metricsFromNamespaces`** — list — default `[{matchLabels: {kubernetes.io/metadata.name: monitoring}}]`\nNamespace selectors permitted to reach port 9091. Must be non-empty when `networkPolicy.enabled=true`.\n\n**`autoscaling.enabled`** — boolean — default `false`\nRender a `HorizontalPodAutoscaler` targeting the StatefulSet. When enabled, the HPA owns the replica count; the static `replicas` value becomes the initial count only.\n\n**`autoscaling.minReplicas`** — integer — default `1`\nHPA minimum replica count.\n\n**`autoscaling.maxReplicas`** — integer — default `3`\nHPA maximum replica count.\n\n**`autoscaling.targetCPUUtilizationPercentage`** — integer — default `80`\nHPA CPU utilization target. Omit or set to `0` to disable CPU-based scaling.\n\n**`autoscaling.targetMemoryUtilizationPercentage`** — integer — not set by default\nHPA memory utilization target. Uncomment in `values.yaml` to enable memory-based scaling.\n\n**`autoscaling.behavior`** — map — default `{}`\nHPA `behavior` block for stabilization windows and scale-up/scale-down policies (`autoscaling/v2` schema).\n\n**`migrate.activeDeadlineSeconds`** — integer — default `600`\n`activeDeadlineSeconds` for the pre-upgrade migration Job.\n\n**`migrate.backoffLimit`** — integer — default `2`\n`backoffLimit` for the migration Job.\n\n**`migrate.resources.requests.cpu`** — string — default `100m`\nCPU request for the migration Job container.\n\n**`migrate.resources.requests.memory`** — string — default `128Mi`\nMemory request for the migration Job container.\n\n**`migrate.resources.limits.cpu`** — string — default `500m`\nCPU limit for the migration Job container.\n\n**`migrate.resources.limits.memory`** — string — default `256Mi`\nMemory limit for the migration Job container.\n\n**`podDisruptionBudget.enabled`** — boolean — default `true`\nRender a `PodDisruptionBudget`. Rendered only when `replicas > 1` or `autoscaling.enabled=true` with `maxReplicas > 1`.\n\n**`podDisruptionBudget.minAvailable`** — integer — default `1`\nMinimum available pods during voluntary disruptions.\n\n**`serviceAccount.create`** — boolean — default `true`\nCreate a dedicated `ServiceAccount`. The ServiceAccount is a Helm pre-install/pre-upgrade hook with weight `-10` so it exists before the migration Job (weight `0`).\n\n**`serviceAccount.name`** — string — default `\"\"` (resolved to the chart fullname)\nName of the ServiceAccount. When empty, the chart fullname is used.\n\n**`serviceAccount.annotations`** — map — default `{}`\nAnnotations on the ServiceAccount (e.g. for IRSA/Workload Identity).\n\n**`podAnnotations`** — map — default `{}`\nAnnotations added to all pods in the StatefulSet.\n\n**`podLabels`** — map — default `{}`\nLabels added to all pods in the StatefulSet.\n\n**`nodeSelector`** — map — default `{}`\nNode selector for the StatefulSet pod spec.\n\n**`tolerations`** — list — default `[]`\nTolerations for the StatefulSet pod spec.\n\n**`affinity`** — map — default `{}`\nAffinity rules for the StatefulSet pod spec.\n\n**`nameOverride`** — string — default `\"\"`\nOverride the chart name component of generated resource names.\n\n**`fullnameOverride`** — string — default `\"\"`\nOverride the full generated resource name prefix."
    },
    {
      "name": "CRDS / OBJECTS",
      "body": "The chart renders the following Kubernetes objects. Conditional objects note their enabling value.\n\n**Always rendered:**\n\n- `StatefulSet` (`apps/v1`) — the cyoda workload. `podManagementPolicy: Parallel`. `updateStrategy: RollingUpdate`. No `volumeClaimTemplates` (cyoda is stateless vs. PostgreSQL). Mounts a projected Secret volume at `/etc/cyoda/secrets` (mode `0400`) and an `emptyDir` at `/tmp`. Runs as UID/GID 65532, non-root, `readOnlyRootFilesystem: true`, `allowPrivilegeEscalation: false`, all capabilities dropped, `seccompProfile: RuntimeDefault`.\n- `Service` (`v1`) — ClusterIP Service exposing ports `8080` (http), `9090` (grpc), `9091` (metrics).\n- `Service` (headless, `v1`) — `clusterIP: None`, `publishNotReadyAddresses: true`. Exposes port `7946` TCP and UDP for gossip (memberlist). Used as the `serviceName` for the StatefulSet.\n- `ConfigMap` (`v1`) — non-sensitive env vars loaded via `envFrom`. Contains: `CYODA_HTTP_PORT`, `CYODA_GRPC_PORT`, `CYODA_ADMIN_PORT`, `CYODA_ADMIN_BIND_ADDRESS`, `CYODA_METRICS_REQUIRE_AUTH`, `CYODA_IAM_MODE`, `CYODA_REQUIRE_JWT`, `CYODA_STORAGE_BACKEND`, `CYODA_POSTGRES_AUTO_MIGRATE`, `CYODA_CLUSTER_ENABLED`, `CYODA_SEED_NODES`, `CYODA_LOG_LEVEL`, `CYODA_JWT_ISSUER`, `CYODA_JWT_EXPIRY_SECONDS`, `CYODA_BOOTSTRAP_TENANT_ID`, `CYODA_BOOTSTRAP_USER_ID`, `CYODA_BOOTSTRAP_ROLES`, and `CYODA_BOOTSTRAP_CLIENT_ID` (when `bootstrap.clientId` is set). Is a Helm pre-install/pre-upgrade hook with weight `-10`.\n- `Job` (`batch/v1`) — migration Job running `cyoda migrate`. Helm pre-install/pre-upgrade hook (weight `0`, delete policy `before-hook-creation,hook-succeeded`). Mounts only the Postgres DSN Secret (principle of least privilege). Uses `restartPolicy: Never`.\n- `NetworkPolicy` (`networking.k8s.io/v1`) — rendered when `networkPolicy.enabled=true`. (Conditional.)\n- `Secret` (HMAC) — rendered when `cluster.hmacSecret.existingSecret=\"\"`. Manages the hex-encoded HMAC secret. Auto-generates on first install; reuses on re-render via `lookup`. GitOps safety guard: fails if rendered without live cluster access. (Conditional.)\n- `Secret` (metrics bearer) — rendered when `monitoring.metricsBearer.existingSecret=\"\"`. Manages the static bearer token for `/metrics`. Auto-generates (48-char alphanumeric). GitOps safety guard applies. (Conditional.)\n\n**Conditional:**\n\n- `ServiceAccount` (`v1`) — rendered when `serviceAccount.create=true`. Helm hook weight `-10`.\n- `Secret` (bootstrap) — rendered when `bootstrap.clientId != \"\"` and `bootstrap.clientSecret.existingSecret=\"\"`. Auto-generates (48-char alphanumeric). GitOps safety guard applies.\n- `HorizontalPodAutoscaler` (`autoscaling/v2`) — rendered when `autoscaling.enabled=true`.\n- `PodDisruptionBudget` (`policy/v1`) — rendered when `podDisruptionBudget.enabled=true` and `replicas > 1` or `autoscaling.maxReplicas > 1`.\n- `HTTPRoute` (`gateway.networking.k8s.io/v1`) — rendered when `gateway.enabled=true`. Routes port 8080.\n- `GRPCRoute` (`gateway.networking.k8s.io/v1`) — rendered when `gateway.enabled=true`. Routes port 9090.\n- `Ingress` (HTTP, `networking.k8s.io/v1`) — rendered when `ingress.enabled=true`. Mutually exclusive with `gateway.enabled`.\n- `Ingress` (gRPC, `networking.k8s.io/v1`) — rendered when `ingress.enabled=true`.\n- `ServiceMonitor` (`monitoring.coreos.com/v1`) — rendered when `monitoring.serviceMonitor.enabled=true`. References the metrics bearer Secret for scrape authentication."
    },
    {
      "name": "GENERATING SECRETS",
      "body": "**JWT signing key** — generate an RSA-2048 private key and load it into a Kubernetes Secret:\n\n```\nopenssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out signing.pem\nkubectl create secret generic cyoda-jwt -n cyoda \\\n  --from-file=signing-key.pem=signing.pem\n```\n\n**HMAC secret** — generate 32 bytes of entropy (64 hex chars) and load into a Kubernetes Secret:\n\n```\nkubectl create secret generic cyoda-hmac -n cyoda \\\n  --from-literal=secret=\"$(openssl rand -hex 32)\"\n```\n\nSee `quickstart` for accepted key formats and format-specific `openssl` commands."
    },
    {
      "name": "SECRETS PROVISIONING",
      "body": "The chart never stores credentials in the ConfigMap. All credentials are mounted via a projected Secret volume at `/etc/cyoda/secrets` and read by the binary through `CYODA_*_FILE` env vars. The file paths and corresponding env vars are set in the StatefulSet env block:\n\n- `CYODA_POSTGRES_URL_FILE=/etc/cyoda/secrets/postgres-dsn` — sourced from `postgres.existingSecret` key `postgres.existingSecretKey`.\n- `CYODA_JWT_SIGNING_KEY_FILE=/etc/cyoda/secrets/jwt-signing-key.pem` — sourced from `jwt.existingSecret` key `jwt.existingSecretKey`.\n- `CYODA_HMAC_SECRET_FILE=/etc/cyoda/secrets/hmac-secret` — sourced from the chart-managed or operator-provided HMAC Secret.\n- `CYODA_METRICS_BEARER_FILE=/etc/cyoda/secrets/metrics-bearer` — sourced from the chart-managed or operator-provided metrics bearer Secret.\n- `CYODA_BOOTSTRAP_CLIENT_SECRET_FILE=/etc/cyoda/secrets/bootstrap-client-secret` — sourced from the chart-managed or operator-provided bootstrap Secret. Mounted only when `bootstrap.clientId` is non-empty.\n\nThe projected volume `defaultMode` is `0400` (owner read-only). The pod `securityContext.fsGroup=65532` ensures mounted Secret files are readable by the non-root container user.\n\nThe migration Job mounts only the Postgres DSN Secret (principle of least privilege). It does not receive JWT, HMAC, metrics bearer, or bootstrap credentials.\n\n**GitOps safety guard (HMAC, metrics bearer, bootstrap secrets):** When `existingSecret` is empty, the chart uses `lookup` to detect whether the Secret already exists. On first install with live cluster access, it generates a random value. On subsequent renders, it reuses the existing value. When `lookup` returns no result (because Helm is run without live cluster access — `helm template`, `--dry-run`, Argo CD, first-time `--create-namespace`), the chart fails with an explicit error message. To avoid this: either pre-create the Secret and set `existingSecret`, use `external-secrets-operator`, or create the namespace first with `kubectl create namespace`.\n\n**CYODA_METRICS_REQUIRE_AUTH:** The ConfigMap always sets `CYODA_METRICS_REQUIRE_AUTH=true` on Helm deployments. This forces the binary's coupled-predicate validator to refuse startup if the metrics bearer Secret is absent or empty, providing a belt-and-braces guard against chart misconfiguration."
    },
    {
      "name": "INSTALL / UPGRADE / UNINSTALL",
      "body": "**Install (pre-create secrets, then install):**\n\n```\nkubectl create secret generic cyoda-pg \\\n  --from-literal=dsn=\"postgres://cyoda:secret@pg-host:5432/cyoda?sslmode=require\"\nkubectl create secret generic cyoda-jwt \\\n  --from-literal=signing-key.pem=\"$(cat signing.pem)\"\n\nhelm install cyoda ./deploy/helm/cyoda \\\n  --namespace cyoda \\\n  --create-namespace \\\n  --set postgres.existingSecret=cyoda-pg \\\n  --set jwt.existingSecret=cyoda-jwt \\\n  --set cluster.hmacSecret.existingSecret=cyoda-hmac\n```\n\nNote: `--create-namespace` triggers the GitOps safety guard (namespace does not yet exist when Helm renders). Pre-create the namespace first:\n\n```\nkubectl create namespace cyoda\nkubectl create secret generic cyoda-pg -n cyoda \\\n  --from-literal=dsn=\"postgres://cyoda:secret@pg-host:5432/cyoda?sslmode=require\"\nkubectl create secret generic cyoda-jwt -n cyoda \\\n  --from-literal=signing-key.pem=\"$(cat signing.pem)\"\n\nhelm install cyoda ./deploy/helm/cyoda \\\n  --namespace cyoda \\\n  --set postgres.existingSecret=cyoda-pg \\\n  --set jwt.existingSecret=cyoda-jwt\n```\n\n**Upgrade:**\n\n```\nhelm upgrade --install cyoda ./deploy/helm/cyoda \\\n  --namespace cyoda \\\n  --set postgres.existingSecret=cyoda-pg \\\n  --set jwt.existingSecret=cyoda-jwt \\\n  --reuse-values\n```\n\n**Uninstall:**\n\n```\nhelm uninstall cyoda --namespace cyoda\n```\n\nUninstall does not delete PersistentVolumeClaims, Secrets not managed by the chart, or the namespace. Delete chart-managed Secrets manually if desired:\n\n```\nkubectl delete secret cyoda-hmac cyoda-metrics-bearer -n cyoda\n```\n\n**Dry-run (template rendering without cluster access — HMAC/metrics secret must be pre-created):**\n\n```\nkubectl create secret generic cyoda-hmac -n cyoda --from-literal=secret=placeholder\nkubectl create secret generic cyoda-metrics-bearer -n cyoda --from-literal=bearer=placeholder\nhelm template cyoda ./deploy/helm/cyoda \\\n  --namespace cyoda \\\n  --set postgres.existingSecret=cyoda-pg \\\n  --set jwt.existingSecret=cyoda-jwt \\\n  --set cluster.hmacSecret.existingSecret=cyoda-hmac \\\n  --set monitoring.metricsBearer.existingSecret=cyoda-metrics-bearer\n```"
    },
    {
      "name": "EXAMPLES",
      "body": "**Minimal install (pre-created postgres + jwt secrets):**\n\n**Bare-cluster install** — `gateway.enabled=false` disables the Gateway API route objects. Use this form on clusters without Gateway API CRDs (kind, minikube, most vanilla clusters). The chart falls back to the built-in `Service` for traffic ingress.\n\n```\nkubectl create namespace cyoda\nkubectl create secret generic cyoda-pg -n cyoda \\\n  --from-literal=dsn=\"postgres://cyoda:pass@db.example.com:5432/cyoda?sslmode=require\"\nkubectl create secret generic cyoda-jwt -n cyoda \\\n  --from-literal=signing-key.pem=\"$(cat signing.pem)\"\n\nhelm install cyoda ./deploy/helm/cyoda \\\n  --namespace cyoda \\\n  --set postgres.existingSecret=cyoda-pg \\\n  --set jwt.existingSecret=cyoda-jwt \\\n  --set gateway.enabled=false\n```\n\n**Multi-replica cluster with HPA:**\n\n```\nhelm install cyoda ./deploy/helm/cyoda \\\n  --namespace cyoda \\\n  --set postgres.existingSecret=cyoda-pg \\\n  --set jwt.existingSecret=cyoda-jwt \\\n  --set replicas=3 \\\n  --set autoscaling.enabled=true \\\n  --set autoscaling.minReplicas=3 \\\n  --set autoscaling.maxReplicas=10\n```\n\n**With OTel tracing (via extraEnv):**\n\n```\nhelm install cyoda ./deploy/helm/cyoda \\\n  --namespace cyoda \\\n  --set postgres.existingSecret=cyoda-pg \\\n  --set jwt.existingSecret=cyoda-jwt \\\n  --set extraEnv[0].name=CYODA_OTEL_ENABLED \\\n  --set extraEnv[0].value=true \\\n  --set extraEnv[1].name=OTEL_EXPORTER_OTLP_ENDPOINT \\\n  --set extraEnv[1].value=http://otel-collector.monitoring.svc.cluster.local:4318 \\\n  --set extraEnv[2].name=OTEL_SERVICE_NAME \\\n  --set extraEnv[2].value=cyoda\n```\n\n**With ServiceMonitor for Prometheus Operator:**\n\n```\nhelm install cyoda ./deploy/helm/cyoda \\\n  --namespace cyoda \\\n  --set postgres.existingSecret=cyoda-pg \\\n  --set jwt.existingSecret=cyoda-jwt \\\n  --set monitoring.serviceMonitor.enabled=true \\\n  --set monitoring.serviceMonitor.labels.release=prometheus\n```\n\n**With bootstrap M2M client:**\n\n```\nkubectl create secret generic cyoda-bootstrap -n cyoda \\\n  --from-literal=secret=\"$(openssl rand -base64 36)\"\n\nhelm install cyoda ./deploy/helm/cyoda \\\n  --namespace cyoda \\\n  --set postgres.existingSecret=cyoda-pg \\\n  --set jwt.existingSecret=cyoda-jwt \\\n  --set bootstrap.clientId=m2m-api-client \\\n  --set bootstrap.clientSecret.existingSecret=cyoda-bootstrap \\\n  --set bootstrap.tenantId=acme \\\n  --set-string 'bootstrap.roles=ROLE_ADMIN\\,ROLE_M2M'\n```\n\nHelm treats commas in `--set` as array separators. Use `--set-string` with an escaped comma (`\\,`) or provide the value via a `values.yaml` file to preserve the single string.\n\n**With Gateway API (requires operator-provided Gateway):**\n\n```\nhelm install cyoda ./deploy/helm/cyoda \\\n  --namespace cyoda \\\n  --set postgres.existingSecret=cyoda-pg \\\n  --set jwt.existingSecret=cyoda-jwt \\\n  --set \"gateway.parentRefs[0].name=prod-gateway\" \\\n  --set \"gateway.parentRefs[0].namespace=gateway-system\" \\\n  --set \"gateway.http.hostnames[0]=api.example.com\" \\\n  --set \"gateway.grpc.hostnames[0]=grpc.example.com\"\n```"
    },
    {
      "name": "SEE ALSO",
      "body": "- run\n- config\n- config.database\n- config.auth\n- quickstart"
    }
  ],
  "see_also": [
    "run",
    "config",
    "config.database",
    "config.auth",
    "quickstart"
  ],
  "stability": "stable",
  "actions": []
}
