diff --git a/playbooks/deploy_openshift.yml b/playbooks/deploy_openshift.yml index 6fa4d00..33ffd38 100644 --- a/playbooks/deploy_openshift.yml +++ b/playbooks/deploy_openshift.yml @@ -333,29 +333,86 @@ - never - sno_deploy_service_accounts - roles: - - role: ocp_service_account - ocp_service_account_name: aap-deployer - ocp_service_account_namespace: aap - ocp_service_account_cluster_role_rules: - - apiGroups: [""] - resources: ["namespaces"] - verbs: ["get", "list", "create", "patch"] - - apiGroups: [""] - resources: ["secrets"] - verbs: ["get", "list", "watch", "create", "patch"] - - apiGroups: [""] - resources: ["serviceaccounts"] - verbs: ["get", "list", "watch"] - - apiGroups: ["apps"] - resources: ["deployments"] - verbs: ["get", "list", "watch"] - - apiGroups: ["operators.coreos.com"] - resources: ["operatorgroups", "subscriptions", "clusterserviceversions"] - verbs: ["get", "list", "create", "patch", "watch"] - - apiGroups: ["apiextensions.k8s.io"] - resources: ["customresourcedefinitions"] - verbs: ["get", "list", "watch"] - - apiGroups: ["aap.ansible.com"] - resources: ["ansibleautomationplatforms"] - verbs: ["get", "list", "create", "patch", "watch"] + tasks: + # ---- supabase-deployer ---- + - name: Provision supabase-deployer ServiceAccount + ansible.builtin.include_role: + name: ocp_service_account + vars: + ocp_service_account_name: supabase-deployer + ocp_service_account_namespace: supabase + ocp_service_account_cluster_role_rules: + - apiGroups: [""] + resources: ["namespaces"] + verbs: ["get", "list", "create", "patch", "delete"] + - apiGroups: [""] + resources: ["secrets", "configmaps", "services", "serviceaccounts", + "persistentvolumeclaims", "pods", "endpoints"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["apps"] + resources: ["deployments", "statefulsets", "replicasets"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["rbac.authorization.k8s.io"] + resources: ["clusterroles", "clusterrolebindings", "roles", "rolebindings"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete", "escalate", "bind"] + - apiGroups: ["security.openshift.io"] + resources: ["securitycontextconstraints"] + verbs: ["get", "list", "use"] + - apiGroups: ["route.openshift.io"] + resources: ["routes", "routes/custom-host"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["cert-manager.io"] + resources: ["certificates", "clusterissuers", "issuers"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["storage.k8s.io"] + resources: ["storageclasses"] + verbs: ["get", "list", "watch"] + + - name: Store supabase-deployer token in Vault + community.hashi_vault.vault_kv2_write: + path: toallab/supabase-deployer + engine_mount_point: kv + data: + token: "{{ __ocp_service_account_token }}" + no_log: true + + # ---- aap-deployer ---- + - name: Provision aap-deployer ServiceAccount + ansible.builtin.include_role: + name: ocp_service_account + vars: + ocp_service_account_name: aap-deployer + ocp_service_account_namespace: aap + ocp_service_account_cluster_role_rules: + - apiGroups: [""] + resources: ["namespaces"] + verbs: ["get", "list", "create", "patch"] + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list", "watch", "create", "patch"] + - apiGroups: [""] + resources: ["serviceaccounts"] + verbs: ["get", "list", "watch"] + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "watch"] + - apiGroups: ["operators.coreos.com"] + resources: ["operatorgroups", "subscriptions", "clusterserviceversions"] + verbs: ["get", "list", "create", "patch", "watch"] + - apiGroups: ["apiextensions.k8s.io"] + resources: ["customresourcedefinitions"] + verbs: ["get", "list", "watch"] + - apiGroups: ["aap.ansible.com"] + resources: ["ansibleautomationplatforms"] + verbs: ["get", "list", "create", "patch", "watch"] + + - name: Store aap-deployer token in Vault + community.hashi_vault.vault_kv2_write: + path: toallab/aap-deployer + engine_mount_point: kv + data: + token: "{{ __ocp_service_account_token }}" + no_log: true diff --git a/playbooks/deploy_supabase.yml b/playbooks/deploy_supabase.yml new file mode 100644 index 0000000..11b56f2 --- /dev/null +++ b/playbooks/deploy_supabase.yml @@ -0,0 +1,44 @@ +--- +# Deploy Supabase on OpenShift (SNO) via the supabase-community Helm chart. +# +# Chart: https://github.com/supabase-community/supabase-kubernetes +# +# Prerequisites: +# - cert-manager installed (deploy_openshift.yml --tags sno_deploy_certmanager) +# - NFS StorageClass available (deploy_openshift.yml --tags sno_deploy_nfs) +# - supabase-deployer ServiceAccount provisioned and token saved to 1Password: +# ansible-navigator run playbooks/deploy_openshift.yml --tags sno_deploy_service_accounts +# - vault_supabase_deployer_token set in host_vars (see hashivault_secrets.yml) +# +# All Supabase secrets (JWT, DB password, etc.) are auto-generated on first run +# and stored in Vault at supabase_vault_path. No manual secret setup required. +# +# Usage: +# ansible-navigator run playbooks/deploy_supabase.yml --mode stdout +# ansible-navigator run playbooks/deploy_supabase.yml --mode stdout -e supabase_chart_version=0.0.9 +# ansible-navigator run playbooks/deploy_supabase.yml --mode stdout -e supabase_db_storage_size=20Gi + +- name: Deploy Supabase on OpenShift + hosts: sno.openshift.toal.ca + gather_facts: false + connection: local + + environment: + K8S_AUTH_HOST: "{{ supabase_k8s_api_url }}" + K8S_AUTH_API_KEY: "{{ vault_supabase_deployer_token }}" + K8S_AUTH_VERIFY_SSL: "false" + + pre_tasks: + - name: Verify supabase-deployer token is available + ansible.builtin.assert: + that: + - vault_supabase_deployer_token is defined + - vault_supabase_deployer_token | length > 0 + fail_msg: >- + vault_supabase_deployer_token is not set. + Provision the ServiceAccount with: + ansible-navigator run playbooks/deploy_openshift.yml --tags sno_deploy_service_accounts + Then save the displayed token to 1Password and reference it as vault_supabase_deployer_token. + + roles: + - role: supabase diff --git a/roles/supabase/defaults/main.yml b/roles/supabase/defaults/main.yml new file mode 100644 index 0000000..cd8d3a2 --- /dev/null +++ b/roles/supabase/defaults/main.yml @@ -0,0 +1,65 @@ +--- +# --- Debugging --- +# Set to false to disable no_log and expose secret values in task output. +# Never set false in production or shared environments. +supabase_no_log: true + +# --- Vault --- +# KV2 path where Supabase secrets are stored and retrieved. +# Run with: op run --env-file=~/.ansible.zshenv -- ansible-navigator ... +# so that VAULT_ADDR / VAULT_TOKEN are injected from 1Password. +supabase_vault_mount: kv +supabase_vault_path: supabase + +# --- Helm release --- +supabase_release_name: supabase +supabase_namespace: supabase +supabase_chart_repo_url: https://supabase-community.github.io/supabase-kubernetes +supabase_chart_version: "" # blank = latest + +# --- External access --- +# Studio and API gateway are both served through Kong on this hostname. +# No generic default — must be set in host_vars or group_vars. +supabase_studio_host: "" +supabase_tls_cluster_issuer: "" + +# --- Storage --- +# Empty string uses the cluster's default StorageClass. +supabase_storage_class: "" +supabase_db_storage_size: 10Gi +supabase_functions_storage_size: 1Gi +supabase_imgproxy_storage_size: 1Gi +supabase_minio_storage_size: 20Gi + +# --- Database --- +supabase_db_name: postgres + +# --- Studio branding --- +supabase_org_name: "" +supabase_project_name: "" + +# --- SMTP (set supabase_smtp_enabled: true to configure email auth) --- +supabase_smtp_enabled: false +supabase_smtp_host: "" +supabase_smtp_port: 587 +supabase_smtp_admin_email: "" +supabase_smtp_sender_name: Supabase + +# --- Internal component name overrides --- +# These become predictable Kubernetes resource names (service, SA, etc.) +# Must match deployment.*.fullnameOverride values passed to the chart +supabase_db_fullname: supabase-db +supabase_kong_fullname: supabase-kong +supabase_functions_fullname: supabase-functions +supabase_vector_fullname: supabase-vector + +# --- Docker Hub pull secret (optional) --- +# Set to avoid anonymous rate limiting on docker.io images (kong, timberio/vector, etc.) +# vault_supabase_dockerhub_token must be set in host_vars when enabled. +supabase_dockerhub_enabled: false +supabase_dockerhub_username: "" + +# --- Wait --- +# helm wait is disabled — image pulls from docker.io can take longer than any +# reasonable helm timeout. Pod readiness is checked separately below. +supabase_wait_timeout: 600 diff --git a/roles/supabase/meta/argument_specs.yml b/roles/supabase/meta/argument_specs.yml new file mode 100644 index 0000000..a74c3dc --- /dev/null +++ b/roles/supabase/meta/argument_specs.yml @@ -0,0 +1,120 @@ +--- +argument_specs: + main: + short_description: Deploy Supabase on OpenShift via the supabase-community Helm chart + description: + - Deploys the full Supabase stack (Studio, Kong, Auth, REST, Realtime, Storage, + Meta, Analytics, Functions, ImgProxy, Vector) to an OpenShift cluster. + - All secrets are created as Kubernetes Secrets and referenced via secretRef + so secret material never appears in Helm values. + - OpenShift SCCs are granted before the Helm install so pods start with + correct permissions (anyuid for postgres, privileged for vector). + - An OCP Route with edge TLS termination is created, backed by a cert-manager + Certificate against the configured ClusterIssuer. + - >- + Vault variables (vault_supabase_*) must be defined in host_vars before running. + Generate JWT values at https://supabase.com/docs/guides/self-hosting/docker + Generate secrets with openssl rand -base64 64 + options: + supabase_release_name: + description: Helm release name. + type: str + default: supabase + supabase_namespace: + description: Kubernetes namespace for all Supabase resources. + type: str + default: supabase + supabase_chart_repo_url: + description: URL of the supabase-community Helm chart repository. + type: str + default: https://supabase-community.github.io/supabase-kubernetes + supabase_chart_version: + description: Chart version to install. Empty string means latest. + type: str + default: "" + supabase_studio_host: + description: >- + FQDN for the Studio dashboard and Kong API gateway Route. + Defaults to supabase.apps... + type: str + supabase_tls_cluster_issuer: + description: cert-manager ClusterIssuer name for TLS certificate provisioning. + type: str + default: "" + supabase_storage_class: + description: StorageClass name for all PersistentVolumeClaims (must support ReadWriteOnce). + type: str + default: "" + supabase_db_storage_size: + description: Size of the PostgreSQL PVC. + type: str + default: 10Gi + supabase_functions_storage_size: + description: Size of the Edge Functions PVC. + type: str + default: 1Gi + supabase_imgproxy_storage_size: + description: Size of the ImgProxy PVC. + type: str + default: 1Gi + supabase_minio_storage_size: + description: Size of the MinIO (object storage) PVC. + type: str + default: 20Gi + supabase_db_name: + description: PostgreSQL database name. + type: str + default: postgres + supabase_org_name: + description: Default organisation name shown in Supabase Studio. + type: str + default: "" + supabase_project_name: + description: Default project name shown in Supabase Studio. + type: str + default: "" + supabase_smtp_enabled: + description: >- + Enable SMTP for auth email. When false, auto-confirm is enabled so + sign-ups do not require email verification. + type: bool + default: false + supabase_smtp_host: + description: SMTP server hostname. + type: str + default: "" + supabase_smtp_port: + description: SMTP server port. + type: int + default: 587 + supabase_smtp_admin_email: + description: Admin email address used as From header. + type: str + default: "" + supabase_smtp_sender_name: + description: Display name used in outgoing email. + type: str + default: Supabase + supabase_db_fullname: + description: >- + fullnameOverride for the db component. Determines the ServiceAccount + name that receives the anyuid SCC — must match deployment.db.fullnameOverride + passed to the chart. + type: str + default: supabase-db + supabase_kong_fullname: + description: >- + fullnameOverride for the kong component. Determines the Service name + that the OCP Route targets. + type: str + default: supabase-kong + supabase_vector_fullname: + description: >- + fullnameOverride for the vector component. Determines the ServiceAccount + name that receives the privileged SCC. + type: str + default: supabase-vector + supabase_wait_timeout: + description: Seconds to wait for deployments to become ready. + type: int + default: 600 diff --git a/roles/supabase/meta/main.yml b/roles/supabase/meta/main.yml new file mode 100644 index 0000000..6b3ab0b --- /dev/null +++ b/roles/supabase/meta/main.yml @@ -0,0 +1,13 @@ +--- +galaxy_info: + role_name: supabase + author: ptoal + description: Deploy Supabase on OpenShift via the supabase-community Helm chart + license: MIT + min_ansible_version: "2.14" + platforms: + - name: EL + versions: + - "9" + +dependencies: [] diff --git a/roles/supabase/tasks/main.yml b/roles/supabase/tasks/main.yml new file mode 100644 index 0000000..994b03c --- /dev/null +++ b/roles/supabase/tasks/main.yml @@ -0,0 +1,483 @@ +--- +# Deploy Supabase on OpenShift via the supabase-community Helm chart. +# +# Prerequisites: +# - KUBECONFIG set in environment (or ocp_kubeconfig host var) +# - cert-manager installed with a ClusterIssuer matching supabase_tls_cluster_issuer +# - A ReadWriteOnce-capable StorageClass set via supabase_storage_class +# - Vault variables defined (see meta/argument_specs.yml for the full list) +# +# Architecture: +# - Kong API gateway is the single ingress point (Route: supabase_studio_host) +# - Studio dashboard is served through Kong +# - DB runs as root — anyuid SCC granted to the db ServiceAccount +# - Vector reads /var/log/pods — privileged SCC granted to vector SA +# - All secrets are created as K8s Secrets and referenced via secretRef +# +# OpenShift SCC notes: +# - supabase-db ServiceAccount → anyuid SCC (postgres runs as root) +# - supabase-vector ServiceAccount → privileged SCC (hostPath to /var/log/pods) + +# ------------------------------------------------------------------ +# Step 0: Resolve secrets from Vault (generate and store if missing) +# ------------------------------------------------------------------ +- name: Resolve Supabase secrets from Vault + ansible.builtin.include_tasks: vault_secrets.yml + +# ------------------------------------------------------------------ +# Step 1: Namespace +# ------------------------------------------------------------------ +- name: Create Supabase namespace + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Namespace + metadata: + name: "{{ supabase_namespace }}" + +# ------------------------------------------------------------------ +# Step 2: SCC grants (ClusterRoleBindings before helm so pods start clean) +# ------------------------------------------------------------------ +- name: Create ClusterRole granting anyuid SCC (supabase db) + kubernetes.core.k8s: + state: present + definition: + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: supabase-db-anyuid-scc + rules: + - apiGroups: [security.openshift.io] + resources: [securitycontextconstraints] + verbs: [use] + resourceNames: [anyuid] + +- name: Bind anyuid SCC to supabase db ServiceAccount + kubernetes.core.k8s: + state: present + definition: + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: supabase-db-anyuid-scc + subjects: + - kind: ServiceAccount + name: "{{ supabase_db_fullname }}" + namespace: "{{ supabase_namespace }}" + roleRef: + kind: ClusterRole + name: supabase-db-anyuid-scc + apiGroup: rbac.authorization.k8s.io + +- name: Create ClusterRole granting anyuid SCC (supabase kong) + kubernetes.core.k8s: + state: present + definition: + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: supabase-kong-anyuid-scc + rules: + - apiGroups: [security.openshift.io] + resources: [securitycontextconstraints] + verbs: [use] + resourceNames: [anyuid] + +- name: Bind anyuid SCC to supabase kong ServiceAccount + kubernetes.core.k8s: + state: present + definition: + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: supabase-kong-anyuid-scc + subjects: + - kind: ServiceAccount + name: "{{ supabase_kong_fullname }}" + namespace: "{{ supabase_namespace }}" + roleRef: + kind: ClusterRole + name: supabase-kong-anyuid-scc + apiGroup: rbac.authorization.k8s.io + +- name: Create ClusterRole granting anyuid SCC (supabase functions) + kubernetes.core.k8s: + state: present + definition: + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: supabase-functions-anyuid-scc + rules: + - apiGroups: [security.openshift.io] + resources: [securitycontextconstraints] + verbs: [use] + resourceNames: [anyuid] + +- name: Bind anyuid SCC to supabase functions ServiceAccount + kubernetes.core.k8s: + state: present + definition: + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: supabase-functions-anyuid-scc + subjects: + - kind: ServiceAccount + name: "{{ supabase_functions_fullname }}" + namespace: "{{ supabase_namespace }}" + roleRef: + kind: ClusterRole + name: supabase-functions-anyuid-scc + apiGroup: rbac.authorization.k8s.io + +- name: Create ClusterRole granting privileged SCC (supabase vector) + kubernetes.core.k8s: + state: present + definition: + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: supabase-vector-privileged-scc + rules: + - apiGroups: [security.openshift.io] + resources: [securitycontextconstraints] + verbs: [use] + resourceNames: [privileged] + +- name: Bind privileged SCC to supabase vector ServiceAccount + kubernetes.core.k8s: + state: present + definition: + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: supabase-vector-privileged-scc + subjects: + - kind: ServiceAccount + name: "{{ supabase_vector_fullname }}" + namespace: "{{ supabase_namespace }}" + roleRef: + kind: ClusterRole + name: supabase-vector-privileged-scc + apiGroup: rbac.authorization.k8s.io + +# ------------------------------------------------------------------ +# Step 3: Kubernetes Secrets (no_log — referenced by helm via secretRef) +# ------------------------------------------------------------------ +- name: Create JWT secret + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Secret + metadata: + name: "{{ __supabase_secret_jwt }}" + namespace: "{{ supabase_namespace }}" + type: Opaque + stringData: + anonKey: "{{ __supabase_anon_key }}" + serviceKey: "{{ __supabase_service_key }}" + secret: "{{ __supabase_jwt_secret }}" + no_log: "{{ supabase_no_log }}" + +- name: Create DB secret + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Secret + metadata: + name: "{{ __supabase_secret_db }}" + namespace: "{{ supabase_namespace }}" + type: Opaque + stringData: + password: "{{ __supabase_db_password }}" + database: "{{ supabase_db_name }}" + no_log: "{{ supabase_no_log }}" + +- name: Create dashboard secret + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Secret + metadata: + name: "{{ __supabase_secret_dashboard }}" + namespace: "{{ supabase_namespace }}" + type: Opaque + stringData: + username: "{{ __supabase_dashboard_username }}" + password: "{{ __supabase_dashboard_password }}" + openAiApiKey: "{{ __supabase_openai_api_key }}" + no_log: "{{ supabase_no_log }}" + +- name: Create analytics secret + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Secret + metadata: + name: "{{ __supabase_secret_analytics }}" + namespace: "{{ supabase_namespace }}" + type: Opaque + stringData: + publicAccessToken: "{{ __supabase_analytics_public_token }}" + privateAccessToken: "{{ __supabase_analytics_private_token }}" + no_log: "{{ supabase_no_log }}" + +- name: Create realtime secret + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Secret + metadata: + name: "{{ __supabase_secret_realtime }}" + namespace: "{{ supabase_namespace }}" + type: Opaque + stringData: + secretKeyBase: "{{ __supabase_realtime_secret_key_base }}" + no_log: "{{ supabase_no_log }}" + +- name: Create meta secret + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Secret + metadata: + name: "{{ __supabase_secret_meta }}" + namespace: "{{ supabase_namespace }}" + type: Opaque + stringData: + cryptoKey: "{{ __supabase_meta_crypto_key }}" + no_log: "{{ supabase_no_log }}" + +- name: Create SMTP secret + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Secret + metadata: + name: "{{ __supabase_secret_smtp }}" + namespace: "{{ supabase_namespace }}" + type: Opaque + stringData: + username: "{{ vault_supabase_smtp_username | default('') }}" # set in host_vars + password: "{{ vault_supabase_smtp_password | default('') }}" # set in host_vars + no_log: "{{ supabase_no_log }}" + +# ------------------------------------------------------------------ +# Step 4: Optional Docker Hub pull secret +# Avoids anonymous rate limiting for docker.io images (kong, vector, etc.) +# ------------------------------------------------------------------ +- name: Create Docker Hub pull secret + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Secret + metadata: + name: supabase-dockerhub + namespace: "{{ supabase_namespace }}" + type: kubernetes.io/dockerconfigjson + data: + .dockerconfigjson: >- + {{ {'auths': {'https://index.docker.io/v1/': { + 'username': supabase_dockerhub_username, + 'password': vault_supabase_dockerhub_token, + 'auth': (supabase_dockerhub_username + ':' + vault_supabase_dockerhub_token) | b64encode + }}} | to_json | b64encode }} + no_log: "{{ supabase_no_log }}" + when: supabase_dockerhub_enabled | bool + +# ------------------------------------------------------------------ +# Step 5: Helm install / upgrade +# ------------------------------------------------------------------ +- name: Deploy Supabase via Helm + kubernetes.core.helm: + release_name: "{{ supabase_release_name }}" + chart_ref: supabase + chart_repo_url: "{{ supabase_chart_repo_url }}" + chart_version: "{{ supabase_chart_version | default(omit, true) }}" + release_namespace: "{{ supabase_namespace }}" + create_namespace: false + wait: false + values: + # Use predictable names so SCC bindings and Route target can reference them + deployment: + db: + fullnameOverride: "{{ supabase_db_fullname }}" + kong: + fullnameOverride: "{{ supabase_kong_fullname }}" + functions: + fullnameOverride: "{{ supabase_functions_fullname }}" + vector: + fullnameOverride: "{{ supabase_vector_fullname }}" + + # Secrets via secretRef — keep secret material out of Helm values + secret: + jwt: + secretRef: "{{ __supabase_secret_jwt }}" + db: + secretRef: "{{ __supabase_secret_db }}" + dashboard: + secretRef: "{{ __supabase_secret_dashboard }}" + analytics: + secretRef: "{{ __supabase_secret_analytics }}" + realtime: + secretRef: "{{ __supabase_secret_realtime }}" + meta: + secretRef: "{{ __supabase_secret_meta }}" + smtp: + secretRef: "{{ __supabase_secret_smtp }}" + + # Storage — all PVCs use the configured StorageClass + persistence: + db: + storageClassName: "{{ supabase_storage_class }}" + size: "{{ supabase_db_storage_size }}" + functions: + storageClassName: "{{ supabase_storage_class }}" + size: "{{ supabase_functions_storage_size }}" + imgproxy: + storageClassName: "{{ supabase_storage_class }}" + size: "{{ supabase_imgproxy_storage_size }}" + minio: + storageClassName: "{{ supabase_storage_class }}" + size: "{{ supabase_minio_storage_size }}" + + # Explicit docker.io registry prefix on all docker.io images. + # OpenShift ImageContentSourcePolicy rewrites bare image refs (e.g. kong/kong) + # to the configured mirror; supplying the full docker.io/ hostname forces the + # pull against the real registry and lets the pull secret take effect. + image: + analytics: + repository: docker.io/supabase/logflare + auth: + repository: docker.io/supabase/gotrue + db: + repository: docker.io/supabase/postgres + functions: + repository: docker.io/supabase/edge-runtime + imgproxy: + repository: docker.io/darthsim/imgproxy + kong: + repository: docker.io/kong/kong + meta: + repository: docker.io/supabase/postgres-meta + realtime: + repository: docker.io/supabase/realtime + rest: + repository: docker.io/postgrest/postgrest + storage: + repository: docker.io/supabase/storage-api + studio: + repository: docker.io/supabase/studio + vector: + repository: docker.io/timberio/vector + + # Docker Hub pull secret (only wired in when enabled) + imagePullSecrets: "{{ [{'name': 'supabase-dockerhub'}] if supabase_dockerhub_enabled | bool else [] }}" + + # Disable chart's nginx ingress — we create an OCP Route below + ingress: + enabled: false + + # Studio public URL (used by auth callbacks and the browser) + environment: + studio: + SUPABASE_PUBLIC_URL: "https://{{ supabase_studio_host }}" + STUDIO_DEFAULT_ORGANIZATION: "{{ supabase_org_name }}" + STUDIO_DEFAULT_PROJECT: "{{ supabase_project_name }}" + NEXT_PUBLIC_ENABLE_LOGS: "true" + + # SMTP + auth: + environment: + GOTRUE_SMTP_HOST: "{{ supabase_smtp_host }}" + GOTRUE_SMTP_PORT: "{{ supabase_smtp_port | string }}" + GOTRUE_SMTP_ADMIN_EMAIL: "{{ supabase_smtp_admin_email }}" + GOTRUE_SMTP_SENDER_NAME: "{{ supabase_smtp_sender_name }}" + GOTRUE_MAILER_AUTOCONFIRM: "{{ 'false' if supabase_smtp_enabled else 'true' }}" + +# ------------------------------------------------------------------ +# Step 6: OpenShift Route (edge TLS, cert-manager certificate) +# ------------------------------------------------------------------ +- name: Create TLS Certificate for Supabase + kubernetes.core.k8s: + state: present + definition: + apiVersion: cert-manager.io/v1 + kind: Certificate + metadata: + name: supabase-tls + namespace: "{{ supabase_namespace }}" + spec: + secretName: supabase-tls + issuerRef: + name: "{{ supabase_tls_cluster_issuer }}" + kind: ClusterIssuer + dnsNames: + - "{{ supabase_studio_host }}" + +- name: Create OpenShift Route for Supabase (Kong gateway) + kubernetes.core.k8s: + state: present + definition: + apiVersion: route.openshift.io/v1 + kind: Route + metadata: + name: supabase + namespace: "{{ supabase_namespace }}" + annotations: + cert-manager.io/cluster-issuer: "{{ supabase_tls_cluster_issuer }}" + spec: + host: "{{ supabase_studio_host }}" + to: + kind: Service + name: "{{ supabase_kong_fullname }}" + weight: 100 + port: + targetPort: http + tls: + termination: edge + insecureEdgeTerminationPolicy: Redirect + certificate: "" + key: "" + caCertificate: "" + +# ------------------------------------------------------------------ +# Step 7: Wait for Kong deployment to be healthy +# ------------------------------------------------------------------ +- name: Wait for Kong deployment to be ready + kubernetes.core.k8s_info: + api_version: apps/v1 + kind: Deployment + namespace: "{{ supabase_namespace }}" + name: "{{ supabase_kong_fullname }}" + register: __supabase_kong_deploy + until: >- + __supabase_kong_deploy.resources | length > 0 and + (__supabase_kong_deploy.resources[0].status.readyReplicas | default(0)) >= 1 + retries: "{{ __supabase_wait_retries }}" + delay: 10 + +# ------------------------------------------------------------------ +# Step 8: Summary +# ------------------------------------------------------------------ +- name: Display Supabase deployment summary + ansible.builtin.debug: + msg: + - "Supabase deployment complete!" + - " Namespace : {{ supabase_namespace }}" + - " Studio URL : https://{{ supabase_studio_host }}" + - " Kong service : {{ supabase_kong_fullname }}:8000" + - " DB PVC : {{ supabase_db_storage_size }} ({{ supabase_storage_class }})" + - " SMTP enabled : {{ supabase_smtp_enabled }}" + - "" + - " Login with vault_supabase_dashboard_username / vault_supabase_dashboard_password" diff --git a/roles/supabase/tasks/vault_secrets.yml b/roles/supabase/tasks/vault_secrets.yml new file mode 100644 index 0000000..72a5877 --- /dev/null +++ b/roles/supabase/tasks/vault_secrets.yml @@ -0,0 +1,152 @@ +--- +# Read Supabase secrets from Vault; generate and store any that are missing. +# +# All __supabase_* facts set here are consumed by the K8s secret tasks in main.yml. +# +# JWT note: if jwt_secret is absent OR either JWT token is absent, all three are +# regenerated together — a partial JWT state (e.g. tokens signed by a different +# secret) would break auth across all services. + +# ------------------------------------------------------------------ +# Read existing secrets (may fail if path does not exist yet) +# ------------------------------------------------------------------ +- name: Read supabase secrets from Vault + community.hashi_vault.vault_kv2_get: + path: "{{ supabase_vault_path }}" + engine_mount_point: "{{ supabase_vault_mount }}" + register: __supabase_vault_read + failed_when: false + no_log: "{{ supabase_no_log }}" + +- name: Set existing vault data fact + ansible.builtin.set_fact: + __sv: "{{ __supabase_vault_read.secret | default({}) }}" + no_log: "{{ supabase_no_log }}" + +# ------------------------------------------------------------------ +# Resolve non-JWT secrets: use existing or generate random values +# ------------------------------------------------------------------ +- name: Resolve non-JWT secrets (generate any that are missing) + ansible.builtin.set_fact: + __supabase_db_password: >- + {{ __sv.db_password + | default(lookup('community.general.random_string', length=32, special=false)) }} + __supabase_dashboard_username: >- + {{ __sv.dashboard_username | default('supabase') }} + __supabase_dashboard_password: >- + {{ __sv.dashboard_password + | default(lookup('community.general.random_string', length=24, special=false)) }} + __supabase_analytics_public_token: >- + {{ __sv.analytics_public_token + | default(lookup('community.general.random_string', length=32, special=false)) }} + __supabase_analytics_private_token: >- + {{ __sv.analytics_private_token + | default(lookup('community.general.random_string', length=32, special=false)) }} + __supabase_realtime_secret_key_base: >- + {{ __sv.realtime_secret_key_base + | default(lookup('community.general.random_string', length=64, special=false)) }} + __supabase_meta_crypto_key: >- + {{ __sv.meta_crypto_key + | default(lookup('community.general.random_string', length=32, special=false)) }} + __supabase_openai_api_key: >- + {{ __sv.openai_api_key | default('') }} + no_log: "{{ supabase_no_log }}" + +# ------------------------------------------------------------------ +# JWT secret + signed tokens (must be generated as a coherent set) +# ------------------------------------------------------------------ +- name: Check whether JWT values need to be (re)generated + ansible.builtin.set_fact: + __supabase_needs_jwt_gen: >- + {{ not (__sv.jwt_secret | default('') | string | length > 0) + or not (__sv.anon_key | default('') | string | length > 0) + or not (__sv.service_key | default('') | string | length > 0) }} + +- name: Generate JWT signing secret + ansible.builtin.set_fact: + __supabase_jwt_secret: >- + {{ lookup('community.general.random_string', length=64, special=false) }} + no_log: "{{ supabase_no_log }}" + when: __supabase_needs_jwt_gen | bool + +- name: Use existing JWT signing secret + ansible.builtin.set_fact: + __supabase_jwt_secret: "{{ __sv.jwt_secret }}" + no_log: "{{ supabase_no_log }}" + when: not __supabase_needs_jwt_gen | bool + +- name: Generate anon and service_role JWT tokens + ansible.builtin.command: + argv: + - python3 + - -c + - | + import hmac, hashlib, base64, json, time, sys + + def b64url(data): + if isinstance(data, str): + data = data.encode() + return base64.urlsafe_b64encode(data).rstrip(b'=').decode() + + def make_jwt(secret, role): + now = int(time.time()) + header = b64url(json.dumps({'alg': 'HS256', 'typ': 'JWT'}, separators=(',', ':'))) + payload = b64url(json.dumps( + {'role': role, 'iss': 'supabase', 'iat': now, 'exp': now + 157680000}, + separators=(',', ':') + )) + msg = '{}.{}'.format(header, payload) + sig = b64url(hmac.new(secret.encode(), msg.encode(), hashlib.sha256).digest()) + return '{}.{}'.format(msg, sig) + + secret = sys.argv[1] + print(make_jwt(secret, 'anon')) + print(make_jwt(secret, 'service_role')) + - "{{ __supabase_jwt_secret }}" + register: __supabase_jwt_output + changed_when: false + no_log: "{{ supabase_no_log }}" + when: __supabase_needs_jwt_gen | bool + +- name: Set generated JWT token facts + ansible.builtin.set_fact: + __supabase_anon_key: "{{ __supabase_jwt_output.stdout_lines[0] }}" + __supabase_service_key: "{{ __supabase_jwt_output.stdout_lines[1] }}" + no_log: "{{ supabase_no_log }}" + when: __supabase_needs_jwt_gen | bool + +- name: Use existing JWT tokens from Vault + ansible.builtin.set_fact: + __supabase_anon_key: "{{ __sv.anon_key }}" + __supabase_service_key: "{{ __sv.service_key }}" + no_log: "{{ supabase_no_log }}" + when: not __supabase_needs_jwt_gen | bool + +# ------------------------------------------------------------------ +# Write back to Vault (always — ensures all fields are present and +# any newly generated values are persisted before K8s secrets are written) +# ------------------------------------------------------------------ +- name: Write supabase secrets to Vault + community.hashi_vault.vault_kv2_write: + path: "{{ supabase_vault_path }}" + engine_mount_point: "{{ supabase_vault_mount }}" + data: + jwt_secret: "{{ __supabase_jwt_secret }}" + anon_key: "{{ __supabase_anon_key }}" + service_key: "{{ __supabase_service_key }}" + db_password: "{{ __supabase_db_password }}" + dashboard_username: "{{ __supabase_dashboard_username }}" + dashboard_password: "{{ __supabase_dashboard_password }}" + analytics_public_token: "{{ __supabase_analytics_public_token }}" + analytics_private_token: "{{ __supabase_analytics_private_token }}" + realtime_secret_key_base: "{{ __supabase_realtime_secret_key_base }}" + meta_crypto_key: "{{ __supabase_meta_crypto_key }}" + openai_api_key: "{{ __supabase_openai_api_key }}" + no_log: "{{ supabase_no_log }}" + +- name: Report vault secret status + ansible.builtin.debug: + msg: >- + {{ 'Generated and stored new Supabase secrets in Vault' + if __supabase_needs_jwt_gen | bool + else 'Using existing Supabase secrets from Vault' }} diff --git a/roles/supabase/vars/main.yml b/roles/supabase/vars/main.yml new file mode 100644 index 0000000..6c62c59 --- /dev/null +++ b/roles/supabase/vars/main.yml @@ -0,0 +1,13 @@ +--- +# Computed internal vars — not intended for override + +__supabase_wait_retries: "{{ (supabase_wait_timeout | int / 10) | int }}" + +# Kubernetes secret names created before helm install; referenced via secretRef +__supabase_secret_jwt: supabase-jwt +__supabase_secret_db: supabase-db +__supabase_secret_dashboard: supabase-dashboard +__supabase_secret_analytics: supabase-analytics +__supabase_secret_realtime: supabase-realtime +__supabase_secret_meta: supabase-meta +__supabase_secret_smtp: supabase-smtp