From 11e2b963f0691e5d41c7d59502369bf747eba50c Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 7 Feb 2026 18:03:19 +0800 Subject: [PATCH 01/21] docs(design): add Sequin + Powersync + Meilisearch integration design Complete design for extending cloudnative-supabase operator with three optional add-ons: - Sequin: CDC/event streaming (with Redis dependency) - Powersync: Offline-first sync (with auto CDC config) - Meilisearch: Full-text search Key decisions: - Operator-managed via optional CRD specs (presence = enabled) - Auto-generate all secrets (zero-config deployment) - Fully automatic CDC setup (roles, publications, schemas) - Hybrid Redis (external or bundled) - Production-ready defaults from flicknote-deploy - Deploy after core Supabase services Includes FlickNote reference example matching exact flicknote-deploy configuration. --- ...-07-sequin-powersync-meilisearch-design.md | 1171 +++++++++++++++++ 1 file changed, 1171 insertions(+) create mode 100644 docs/plans/2026-02-07-sequin-powersync-meilisearch-design.md diff --git a/docs/plans/2026-02-07-sequin-powersync-meilisearch-design.md b/docs/plans/2026-02-07-sequin-powersync-meilisearch-design.md new file mode 100644 index 0000000..6e4c4d3 --- /dev/null +++ b/docs/plans/2026-02-07-sequin-powersync-meilisearch-design.md @@ -0,0 +1,1171 @@ +# Sequin + Powersync + Meilisearch Integration Design + +**Date:** 2026-02-07 +**Status:** Design Complete - Ready for Implementation +**Author:** Claude (via brainstorming session) + +## Overview + +Extend cloudnative-supabase operator to support three optional add-on services for complete stack deployment: + +1. **Sequin** - CDC/event streaming for real-time data pipelines +2. **Powersync** - Offline-first sync for mobile/web applications +3. **Meilisearch** - Fast full-text search engine + +**Goal:** Enable complete Supabase + CDC + Search stack deployment in minutes with minimal configuration. + +## Design Decisions Summary + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Integration pattern | Operator-managed via optional CRD specs | Single source of truth, proper initialization order | +| Optionality | Optional spec sections (presence = enabled) | Matches existing backup/recovery pattern | +| Redis dependency | Hybrid: external reference or auto-deployed | Flexibility for dev (bundled) vs prod (external) | +| CDC configuration | Fully automatic (roles, publications, schemas) | Minimal user config, reduces misconfiguration | +| Sync rules | Both inline and ConfigMap reference | Matches flicknote-deploy pattern | +| Image versions | Pinned stable defaults with full override | Production reliability, allows customization | +| Resource defaults | Production-ready from flicknote-deploy | Battle-tested values, overridable | +| Health checks | Standard K8s probes, individual conditions | Clear observability, standard patterns | +| Service exposure | ClusterIP only | Cloudflare Tunnel for external access | +| Monitoring | Expose metrics ports, no ServiceMonitors | Stack-agnostic (Prometheus, VictoriaMetrics, etc.) | +| Deployment order | After core Supabase services | Treats CDC as enhancement layer | +| Secret generation | Auto-generate all secrets | Zero-config deployment | + +## CRD API Structure + +### Top-level Spec Extensions + +```go +type SupabaseProjectSpec struct { + // ... existing fields (Database, Auth, Rest, Studio, Meta, Kong) ... + + // Sequin CDC/event streaming configuration (optional) + // +optional + Sequin *SequinSpec `json:"sequin,omitempty"` + + // Powersync offline-first sync configuration (optional) + // +optional + Powersync *PowersyncSpec `json:"powersync,omitempty"` + + // Meilisearch full-text search configuration (optional) + // +optional + Meilisearch *MeilisearchSpec `json:"meilisearch,omitempty"` +} +``` + +### SequinSpec + +```go +type SequinSpec struct { + // Image configuration (default: sequin/sequin:v0.13.25) + // +optional + Image ImageSpec `json:"image,omitempty"` + + // Replicas (default: 1) + // +kubebuilder:default=1 + // +optional + Replicas int32 `json:"replicas,omitempty"` + + // Resources (defaults: 256Mi/100m CPU → 512Mi/500m CPU) + // +optional + Resources corev1.ResourceRequirements `json:"resources,omitempty"` + + // Redis configuration - external or bundled + // +optional + Redis RedisSpec `json:"redis,omitempty"` + + // Account/user configuration (defaults provided) + // +optional + Account *SequinAccountSpec `json:"account,omitempty"` +} + +type RedisSpec struct { + // External Redis reference (if nil, operator deploys minimal Redis) + // +optional + External *ExternalRedisSpec `json:"external,omitempty"` + + // Resources for bundled Redis (default: 256Mi/50m → 256Mi/100m) + // +optional + Resources corev1.ResourceRequirements `json:"resources,omitempty"` + + // Persistence for bundled Redis + // +optional + Storage RedisPersistenceSpec `json:"storage,omitempty"` +} + +type ExternalRedisSpec struct { + // Host of external Redis instance + // +required + Host string `json:"host"` + + // Port (default: 6379) + // +kubebuilder:default=6379 + // +optional + Port int32 `json:"port,omitempty"` + + // PasswordSecretRef for Redis AUTH (optional) + // +optional + PasswordSecretRef string `json:"passwordSecretRef,omitempty"` +} + +type RedisPersistenceSpec struct { + // StorageClass (default: "" = cluster default) + // +optional + StorageClass string `json:"storageClass,omitempty"` + + // Size (default: 1Gi) + // +kubebuilder:default="1Gi" + // +optional + Size string `json:"size,omitempty"` +} + +type SequinAccountSpec struct { + // Account name (default: "default") + // +kubebuilder:default="default" + // +optional + Name string `json:"name,omitempty"` + + // Admin user email (default: "admin@example.com") + // +optional + Email string `json:"email,omitempty"` +} +``` + +### PowersyncSpec + +```go +type PowersyncSpec struct { + // Image configuration (default: journeyapps/powersync-service:1.18.2) + // +optional + Image ImageSpec `json:"image,omitempty"` + + // API deployment configuration (client-facing) + // +optional + API PowersyncAPISpec `json:"api,omitempty"` + + // Replication deployment configuration (CDC processing) + // +optional + Replication PowersyncReplicationSpec `json:"replication,omitempty"` + + // Sync rules configuration + // +optional + SyncRules SyncRulesSpec `json:"syncRules,omitempty"` + + // Compact CronJob configuration + // +optional + Compact PowersyncCompactSpec `json:"compact,omitempty"` +} + +type PowersyncAPISpec struct { + // Replicas (default: 2) + // +kubebuilder:default=2 + // +optional + Replicas int32 `json:"replicas,omitempty"` + + // Resources (default: 360Mi/100m → 360Mi/1cpu) + // +optional + Resources corev1.ResourceRequirements `json:"resources,omitempty"` + + // NodeOptions for heap size (default: "--max-old-space-size=330") + // +optional + NodeOptions string `json:"nodeOptions,omitempty"` +} + +type PowersyncReplicationSpec struct { + // Resources (default: 512Mi/100m → 512Mi/1cpu) + // +optional + Resources corev1.ResourceRequirements `json:"resources,omitempty"` + + // NodeOptions for heap size (default: "--max-old-space-size=482") + // +optional + NodeOptions string `json:"nodeOptions,omitempty"` +} + +type SyncRulesSpec struct { + // Inline sync rules (YAML string) + // If both Inline and ConfigMapRef are empty, uses default todolist example + // +optional + Inline string `json:"inline,omitempty"` + + // Reference to external ConfigMap containing sync rules + // Takes precedence over Inline if both provided + // +optional + ConfigMapRef string `json:"configMapRef,omitempty"` +} + +type PowersyncCompactSpec struct { + // Enabled (default: true) + // +kubebuilder:default=true + // +optional + Enabled bool `json:"enabled"` + + // Schedule in cron format (default: "0 3 * * *" = 3am daily) + // +kubebuilder:default="0 3 * * *" + // +optional + Schedule string `json:"schedule,omitempty"` + + // Resources (default: 256Mi/100m → 1Gi/500m) + // +optional + Resources corev1.ResourceRequirements `json:"resources,omitempty"` +} +``` + +### MeilisearchSpec + +```go +type MeilisearchSpec struct { + // Image configuration (default: getmeili/meilisearch:v1.11.0) + // +optional + Image ImageSpec `json:"image,omitempty"` + + // Replicas (default: 1) + // +kubebuilder:default=1 + // +optional + Replicas int32 `json:"replicas,omitempty"` + + // Resources (default: 512Mi/250m → 2Gi/500m) + // +optional + Resources corev1.ResourceRequirements `json:"resources,omitempty"` + + // Persistence configuration + // +optional + Persistence PersistenceSpec `json:"persistence,omitempty"` + + // MasterKeySecretRef for existing secret (optional) + // If not provided, operator auto-generates master key + // +optional + MasterKeySecretRef string `json:"masterKeySecretRef,omitempty"` +} + +type PersistenceSpec struct { + // StorageClass (default: "" = cluster default) + // +optional + StorageClass string `json:"storageClass,omitempty"` + + // Size (default: 10Gi) + // +kubebuilder:default="10Gi" + // +optional + Size string `json:"size,omitempty"` +} +``` + +### Common ImageSpec + +```go +// ImageSpec defines container image configuration +type ImageSpec struct { + // Registry (default: docker.io) + // +optional + Registry string `json:"registry,omitempty"` + + // Repository (e.g., sequin/sequin, guionai/sequin) + // +optional + Repository string `json:"repository,omitempty"` + + // Tag (pinned stable version per service) + // +optional + Tag string `json:"tag,omitempty"` + + // PullPolicy (default: IfNotPresent) + // +kubebuilder:default=IfNotPresent + // +optional + PullPolicy string `json:"pullPolicy,omitempty"` +} +``` + +## Database & CDC Auto-Configuration + +When `spec.sequin` or `spec.powersync` are present, the operator automatically configures CDC infrastructure. + +### Automatic CNPG Roles + +Added to `spec.database.additionalRoles` in the CNPG Cluster: + +```yaml +# When spec.sequin exists: +- name: sequin + ensure: present + login: true + passwordSecret: + name: -sequin-password + +- name: sequin_replication + ensure: present + login: true + replication: true + bypassrls: true + passwordSecret: + name: -sequin-replication-password + +# When spec.powersync exists: +- name: powersync_storage + ensure: present + login: true + passwordSecret: + name: -powersync-storage-password +``` + +### Automatic Database Resources + +```yaml +# Sequin gets its own database with citext extension +additionalDatabases: + - name: sequin + owner: sequin + extensions: + - name: citext + ensure: present + +# Powersync schema in main Supabase database +bootstrapDatabase: + schemas: + - name: powersync + owner: powersync_storage + +# CDC Publications for change data capture +publications: + - name: sequin_pub + publicationName: sequin_pub + database: supabase + target: + objects: + - tablesInSchema: public + + - name: powersync + database: supabase + target: + objects: + - tablesInSchema: public +``` + +### CDC Permissions SQL + +**Status:** Research needed (Task #1) + +The following SQL grants CDC roles appropriate permissions: + +```sql +-- Grant CDC role (sequin_replication) read access to public schema only +-- More restrictive than pg_read_all_data (avoids system schema access) + +GRANT USAGE ON SCHEMA public TO sequin_replication; +GRANT CREATE ON DATABASE supabase TO sequin; +ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public + GRANT SELECT ON TABLES TO sequin_replication; +``` + +**Open question:** When/how to run this SQL? +- Option A: Include in initSQL ConfigMap (runs during cluster bootstrap) +- Option B: Separate Job after cluster + roles ready (flicknote-deploy pattern) + +See Task #1 for research on CNPG bootstrap ordering. + +### Secret Generation + +Operator auto-generates these secrets if not provided: + +| Secret Name | Keys | Purpose | +|-------------|------|---------| +| `-sequin` | `secretKeyBase`, `vaultKey`, `apiToken` | Sequin encryption + API auth | +| `-sequin-password` | `username`, `password` | Sequin database role | +| `-sequin-replication-password` | `username`, `password` | CDC replication role | +| `-powersync-storage-password` | `username`, `password` | Powersync storage role | +| `-meilisearch-master-key` | `masterKey` | Meilisearch admin auth | + +Secrets are only generated if they don't already exist (prevents regeneration on operator restart). + +## Deployment Resources + +### Sequin Resources (when `spec.sequin` present) + +1. **Secret**: `-sequin` + ```yaml + data: + secretKeyBase: # 64 bytes + vaultKey: # 32 bytes + apiToken: # API token for CLI + ``` + +2. **Deployment**: `-sequin` + ```yaml + spec: + replicas: 1 # from spec.sequin.replicas + template: + spec: + containers: + - name: sequin + image: sequin/sequin:v0.13.25 # or user override + env: + - name: DATABASE_URL + value: postgres://sequin:@:5432/sequin + - name: REDIS_URL + value: redis://:6379 + - name: SECRET_KEY_BASE + valueFrom: + secretKeyRef: + name: -sequin + key: secretKeyBase + # ... other env vars + resources: + requests: + memory: 256Mi + cpu: 100m + limits: + memory: 512Mi + cpu: 500m + ``` + +3. **Service**: `-sequin` + ```yaml + spec: + type: ClusterIP + ports: + - port: 7376 + name: http + - port: 4000 # metrics + name: metrics + ``` + +4. **Redis** (if `spec.sequin.redis.external` not provided): + - **StatefulSet**: `-sequin-redis` (1 replica) + - **Service**: `-sequin-redis` (ClusterIP, port 6379) + - **PVC**: `redis-data--sequin-redis-0` (1Gi) + +### Powersync Resources (when `spec.powersync` present) + +1. **ConfigMap**: `-powersync-config` + ```yaml + data: + config.json: | + { + "storage": { + "type": "postgresql", + "uri": "postgres://:5432/supabase", + "username": "powersync_storage", + "password": "" + }, + "replication": { + "connections": [{ + "type": "postgresql", + "uri": "postgres://:5432/supabase", + "username": "sequin_replication", + "password": "", + "tag": "default" + }] + }, + "client_auth": { + "supabase": true, + "supabase_jwt_secret": "", + "audience": ["authenticated"] + }, + "sync_rules": { + "path": "/powersync/sync_rules/sync_rules.yaml" + } + } + ``` + +2. **ConfigMap**: `-powersync-sync-rules` + ```yaml + data: + sync_rules.yaml: | + # From spec.powersync.syncRules.inline + # OR references external ConfigMap + # OR default todolist example: + bucket_definitions: + global: + data: + - select _id as id, * from lists + - select _id as id, * from todos + ``` + +3. **Deployment**: `-powersync-api` + ```yaml + spec: + replicas: 2 # from spec.powersync.api.replicas + template: + spec: + containers: + - name: powersync + image: journeyapps/powersync-service:1.18.2 + command: ["node", "dist/src/entry-api.js"] + env: + - name: NODE_OPTIONS + value: "--max-old-space-size=330" + - name: POWERSYNC_CONFIG_PATH + value: "/powersync/config/config.json" + volumeMounts: + - name: config + mountPath: /powersync/config + - name: sync-rules + mountPath: /powersync/sync_rules + resources: + requests: + memory: 360Mi + cpu: 100m + limits: + memory: 360Mi + cpu: 1 + ``` + +4. **Deployment**: `-powersync-replication` + - Same image, different command: `["node", "dist/src/entry-replication.js"]` + - Resources: 512Mi/100m → 512Mi/1cpu + - NODE_OPTIONS: `--max-old-space-size=482` + +5. **Service**: `-powersync` + ```yaml + spec: + type: ClusterIP + ports: + - port: 8080 + name: http + - port: 9464 + name: metrics + ``` + +6. **CronJob**: `-powersync-compact` + ```yaml + spec: + schedule: "0 3 * * *" # 3am daily + jobTemplate: + spec: + template: + spec: + containers: + - name: compact + image: journeyapps/powersync-service:1.18.2 + command: ["node", "dist/src/entry-compact.js"] + ``` + +### Meilisearch Resources (when `spec.meilisearch` present) + +1. **Secret**: `-meilisearch-master-key` + ```yaml + data: + masterKey: # 32 bytes + ``` + +2. **StatefulSet**: `-meilisearch` + ```yaml + spec: + replicas: 1 + volumeClaimTemplates: + - metadata: + name: data + spec: + storageClassName: "" # cluster default + resources: + requests: + storage: 10Gi + template: + spec: + containers: + - name: meilisearch + image: getmeili/meilisearch:v1.11.0 + env: + - name: MEILI_ENV + value: "production" + - name: MEILI_NO_ANALYTICS + value: "true" + - name: MEILI_EXPERIMENTAL_LOGS_MODE + value: "json" + - name: MEILI_MASTER_KEY + valueFrom: + secretKeyRef: + name: -meilisearch-master-key + key: masterKey + volumeMounts: + - name: data + mountPath: /meili_data + resources: + requests: + memory: 512Mi + cpu: 250m + limits: + memory: 2Gi + cpu: 500m + ``` + +3. **Service**: `-meilisearch` + ```yaml + spec: + type: ClusterIP + ports: + - port: 7700 + name: http + ``` + +### Owner References + +All resources have `ownerReferences` pointing to the SupabaseProject for automatic garbage collection on delete. + +## Status & Observability + +### New Status Conditions + +```go +const ( + // ... existing conditions (Ready, DatabaseReady, AuthReady, etc.) ... + + // Sequin conditions + ConditionTypeSequinReady = "SequinReady" + ConditionTypeSequinDatabaseReady = "SequinDatabaseReady" + + // Powersync conditions + ConditionTypePowersyncReady = "PowersyncReady" + ConditionTypePowersyncStorageReady = "PowersyncStorageReady" + + // Meilisearch condition + ConditionTypeMeilisearchReady = "MeilisearchReady" +) +``` + +### Extended ServicesStatus + +```go +type ServicesStatus struct { + // Existing services + Auth ServiceStatus `json:"auth,omitempty"` + Rest ServiceStatus `json:"rest,omitempty"` + Studio ServiceStatus `json:"studio,omitempty"` + Meta ServiceStatus `json:"meta,omitempty"` + Kong ServiceStatus `json:"kong,omitempty"` + + // New services + Sequin ServiceStatus `json:"sequin,omitempty"` + PowersyncAPI ServiceStatus `json:"powersyncApi,omitempty"` + PowersyncReplication ServiceStatus `json:"powersyncReplication,omitempty"` + Meilisearch ServiceStatus `json:"meilisearch,omitempty"` +} +``` + +### Health Checks + +All deployments use standard Kubernetes readiness/liveness probes: + +**Sequin:** +```yaml +livenessProbe: + httpGet: + path: /health + port: 7376 + initialDelaySeconds: 30 + periodSeconds: 10 +readinessProbe: + httpGet: + path: /health + port: 7376 + initialDelaySeconds: 10 + periodSeconds: 5 +``` + +**Powersync API/Replication:** +```yaml +livenessProbe: + httpGet: + path: /api/health + port: 8080 + initialDelaySeconds: 30 +readinessProbe: + httpGet: + path: /api/health + port: 8080 + initialDelaySeconds: 10 +``` + +**Meilisearch:** +```yaml +livenessProbe: + httpGet: + path: /health + port: 7700 + initialDelaySeconds: 30 +readinessProbe: + httpGet: + path: /health + port: 7700 + initialDelaySeconds: 10 +``` + +### Status Update Logic + +Operator watches Deployment/StatefulSet status and updates conditions: + +1. **SequinDatabaseReady**: True after Sequin database + roles created in CNPG +2. **SequinReady**: True when Sequin deployment has `availableReplicas >= 1` +3. **PowersyncStorageReady**: True after powersync schema + publications created +4. **PowersyncReady**: True when both API and Replication deployments have `availableReplicas >= 1` +5. **MeilisearchReady**: True when StatefulSet has `readyReplicas >= 1` + +**Overall Ready condition:** True when all enabled services are ready (including CDC/search if specs present). + +### Metrics Exposure + +All services expose Prometheus metrics on dedicated ports (no ServiceMonitor resources created): + +| Service | Metrics Port | Endpoint | +|---------|--------------|----------| +| Sequin | 4000 | `/metrics` | +| Powersync | 9464 | `/metrics` | +| Meilisearch | 7700 | `/metrics` | +| Redis (bundled) | 9121 | `/metrics` (via redis-exporter sidecar, optional) | + +Users add ServiceMonitor/PodMonitor resources based on their monitoring stack. + +## Reconciliation Flow + +### Updated Controller Logic + +```go +func (r *SupabaseProjectReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + project := &v1alpha1.SupabaseProject{} + // ... fetch project ... + + // Phase 1: Secrets + if err := r.reconcileSecrets(ctx, project); err != nil { + return ctrl.Result{}, err + } + // Generates: + // - JWT secret (existing) + // - Database passwords (existing) + // - Sequin secrets (if spec.sequin != nil) + // - Powersync passwords (if spec.powersync != nil) + // - Meilisearch master key (if spec.meilisearch != nil) + + // Phase 2: InitSQL ConfigMap + if err := r.reconcileInitSQL(ctx, project); err != nil { + return ctrl.Result{}, err + } + // Includes standard Supabase init SQL + // TODO: Add CDC permissions SQL (pending Task #1 research) + + // Phase 3: Backup/Recovery (if enabled) + if err := r.reconcileBackup(ctx, project); err != nil { + return ctrl.Result{}, err + } + + // Phase 4: CNPG Cluster + if err := r.reconcileCNPGCluster(ctx, project); err != nil { + return ctrl.Result{}, err + } + // Extends cluster with: + // - Sequin roles + database (if spec.sequin) + // - Powersync roles + schema (if spec.powersync) + // - Publications (if CDC enabled) + + // Phase 5: Wait for Database Ready + if !r.isDatabaseReady(ctx, project) { + r.setCondition(project, ConditionTypeDatabaseReady, metav1.ConditionFalse, "Waiting", "Database not ready") + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + r.setCondition(project, ConditionTypeDatabaseReady, metav1.ConditionTrue, "Ready", "Database ready") + + // Phase 6: Core Services + if err := r.reconcileCoreServices(ctx, project); err != nil { + return ctrl.Result{}, err + } + // Deploys Auth, REST, Studio, Meta, Kong + + // Phase 7: CDC Services (after core services - flicknote-deploy pattern) + if project.Spec.Sequin != nil { + if err := r.reconcileSequin(ctx, project); err != nil { + r.setCondition(project, ConditionTypeSequinReady, metav1.ConditionFalse, "Error", err.Error()) + return ctrl.Result{}, err + } + } + + if project.Spec.Powersync != nil { + if err := r.reconcilePowersync(ctx, project); err != nil { + r.setCondition(project, ConditionTypePowersyncReady, metav1.ConditionFalse, "Error", err.Error()) + return ctrl.Result{}, err + } + } + + // Phase 8: Search Service + if project.Spec.Meilisearch != nil { + if err := r.reconcileMeilisearch(ctx, project); err != nil { + r.setCondition(project, ConditionTypeMeilisearchReady, metav1.ConditionFalse, "Error", err.Error()) + return ctrl.Result{}, err + } + } + + // Phase 9: Update Overall Status + if err := r.updateStatus(ctx, project); err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} +``` + +### Phase 7 Detail: reconcileSequin + +```go +func (r *SupabaseProjectReconciler) reconcileSequin(ctx context.Context, project *v1alpha1.SupabaseProject) error { + // 1. Deploy Redis (if external not configured) + if project.Spec.Sequin.Redis.External == nil { + if err := r.reconcileSequinRedis(ctx, project); err != nil { + return fmt.Errorf("failed to deploy Redis: %w", err) + } + } + + // 2. Deploy Sequin deployment + deployment := r.buildSequinDeployment(project) + if err := r.createOrUpdate(ctx, deployment); err != nil { + return fmt.Errorf("failed to deploy Sequin: %w", err) + } + + // 3. Deploy Sequin service + service := r.buildSequinService(project) + if err := r.createOrUpdate(ctx, service); err != nil { + return fmt.Errorf("failed to create Sequin service: %w", err) + } + + // 4. Update status + if r.isDeploymentReady(ctx, deployment) { + r.setCondition(project, ConditionTypeSequinReady, metav1.ConditionTrue, "Ready", "Sequin is ready") + } else { + r.setCondition(project, ConditionTypeSequinReady, metav1.ConditionFalse, "Pending", "Waiting for Sequin") + } + + return nil +} +``` + +### Key Implementation Details + +- **Conditional deployment:** Only create resources if spec section exists (`if project.Spec.Sequin != nil`) +- **Secret reuse:** Check if secrets exist before generating (prevent regeneration on restart) +- **CNPG cluster updates:** Merge new roles/databases/publications into existing cluster spec +- **Error handling:** Set appropriate conditions on failure, return error for retry +- **Requeue logic:** Use `RequeueAfter` for waiting on dependencies (e.g., database ready) + +## Example Usage + +### Minimal Example (All Defaults) + +```yaml +apiVersion: supabase.guion.ai/v1alpha1 +kind: SupabaseProject +metadata: + name: my-app + namespace: apps-dev +spec: + database: + instances: 1 + storage: + size: 10Gi + + auth: + siteURL: https://my-app.example.com + externalURL: https://my-app.example.com/auth + + # Enable Sequin with all defaults (auto-deployed Redis) + sequin: {} + + # Enable Powersync with all defaults (todolist sync rules) + powersync: {} + + # Enable Meilisearch with all defaults (10Gi storage) + meilisearch: {} +``` + +**Result:** +- Supabase core stack deployed +- Sequin with bundled Redis (1Gi) +- Powersync with default todolist sync rules +- Meilisearch with 10Gi storage +- All secrets auto-generated +- All CDC roles/publications configured automatically + +### Production Example (Custom Configuration) + +```yaml +apiVersion: supabase.guion.ai/v1alpha1 +kind: SupabaseProject +metadata: + name: production-app + namespace: apps-prod +spec: + database: + instances: 3 + storage: + size: 100Gi + storageClass: longhorn + + auth: + siteURL: https://app.example.com + externalURL: https://app.example.com/auth + + # Sequin with external Redis + sequin: + replicas: 2 + redis: + external: + host: redis.infra-prod.svc + port: 6379 + resources: + requests: + memory: 512Mi + cpu: 200m + limits: + memory: 1Gi + cpu: 1 + + # Powersync with custom sync rules ConfigMap + powersync: + api: + replicas: 3 + resources: + requests: + memory: 512Mi + cpu: 200m + limits: + memory: 1Gi + cpu: 2 + replication: + resources: + requests: + memory: 1Gi + cpu: 200m + limits: + memory: 2Gi + cpu: 2 + syncRules: + configMapRef: my-custom-sync-rules + compact: + schedule: "0 2 * * *" # 2am daily + + # Meilisearch with larger storage + meilisearch: + replicas: 2 + persistence: + size: 50Gi + storageClass: longhorn + resources: + requests: + memory: 1Gi + cpu: 500m + limits: + memory: 4Gi + cpu: 2 +``` + +### FlickNote Reference Example + +Replicates exact flicknote-deploy configuration: + +```yaml +apiVersion: supabase.guion.ai/v1alpha1 +kind: SupabaseProject +metadata: + name: flicknote + namespace: apps-prod +spec: + database: + instances: 1 + storage: + size: 20Gi + storageClass: local-path + + auth: + siteURL: https://flicknote.app + externalURL: https://api.flicknote.app/auth + + # Sequin - FlickNote custom fork + sequin: + image: + registry: ghcr.io + repository: guionai/sequin + tag: flicknote + pullPolicy: IfNotPresent + replicas: 1 + redis: + external: + host: redis.infra-prod.svc + port: 6379 + resources: + requests: + memory: 256Mi + cpu: 100m + limits: + memory: 512Mi + cpu: 500m + + # Powersync - matches flicknote-deploy values + powersync: + image: + repository: journeyapps/powersync-service + tag: "1.18.2" + pullPolicy: IfNotPresent + api: + replicas: 2 + resources: + requests: + memory: 360Mi + cpu: 100m + limits: + memory: 360Mi + cpu: 1 + nodeOptions: "--max-old-space-size=330" + replication: + resources: + requests: + memory: 512Mi + cpu: 100m + limits: + memory: 512Mi + cpu: 1 + nodeOptions: "--max-old-space-size=482" + syncRules: + configMapRef: powersync-sync-rules # External ConfigMap from Tanka + compact: + schedule: "0 3 * * *" + + # Meilisearch - matches flicknote-deploy values + meilisearch: + image: + repository: getmeili/meilisearch + tag: v1.11.0 + replicas: 1 + persistence: + size: 10Gi + storageClass: local-path + resources: + requests: + memory: 512Mi + cpu: 250m + limits: + memory: 2Gi + cpu: 500m +``` + +## Open Questions & Research Tasks + +### Task #1: CDC Permissions SQL Ordering + +**Status:** Research in progress + +**Question:** When/how should CDC permissions SQL execute? + +```sql +GRANT USAGE ON SCHEMA public TO sequin_replication; +GRANT CREATE ON DATABASE supabase TO sequin; +ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public + GRANT SELECT ON TABLES TO sequin_replication; +``` + +**Options:** +1. **InitSQL ConfigMap** - Include in `bootstrap.initdb.postInitApplicationSQL` + - Issue: Does initSQL run before or after managed roles are created? + - If before: GRANT fails (roles don't exist yet) + +2. **Separate Job** - Run after CNPG cluster + roles ready (flicknote-deploy pattern) + - Pros: Clear ordering, matches existing pattern + - Cons: Adds complexity, need status tracking + +3. **CNPG postInitTemplateSQL** - Check if CNPG supports post-role-creation hooks + +**Research needed:** +- CNPG bootstrap execution order documentation +- Test initSQL vs managed.roles timing +- Idempotency pattern for Job approach + +### Other Open Questions + +1. **Sequin init configuration** - How to inject account/user/API token setup? + - Current: Uses `configuration` field in Helm chart + - Need: Operator approach for initial setup + +2. **Powersync default sync rules** - Exact YAML to use for default + - Copy from flicknote-deploy chart's todolist example + +3. **Redis password** - Should bundled Redis have auth enabled? + - flicknote-deploy Redis is passwordless (internal only) + - Bundled Redis should match (ClusterIP, no auth) + +4. **Metrics validation** - Ensure all services expose metrics correctly + - Test Prometheus scraping on deployed services + +## Implementation Phases + +### Phase 1: CRD + Basic Sequin +- Define CRD API structs (SequinSpec, PowersyncSpec, MeilisearchSpec) +- Generate CRD YAML (`make generate manifests`) +- Implement secret generation (Sequin secrets) +- Implement Sequin deployment (without bundled Redis) +- Test with external Redis + +### Phase 2: Powersync + Auto CDC Config +- Implement Powersync deployments (API, Replication, CronJob) +- Implement sync rules ConfigMap generation +- Extend CNPG cluster builder with CDC roles/publications +- Resolve Task #1 (CDC permissions SQL ordering) +- Test CDC integration end-to-end + +### Phase 3: Meilisearch + Bundled Redis +- Implement Meilisearch StatefulSet +- Implement master key generation +- Implement bundled Redis option for Sequin +- Test storage persistence + +### Phase 4: Documentation + Polish +- Write CRD reference documentation +- Write quick start guide +- Write FlickNote configuration guide +- Add E2E tests +- Performance testing + +## Testing Strategy + +### Unit Tests +- Builder functions for all new resources (Sequin, Powersync, Meilisearch) +- Secret generation logic +- CNPG cluster extension logic (roles, publications) + +### Integration Tests +- envtest with CNPG CRDs installed +- Test reconciliation flow with mocked CNPG Cluster +- Test status condition updates + +### E2E Tests +- Deploy to kind cluster +- Create SupabaseProject with all three specs +- Verify all services reach Ready status +- Test CDC functionality (Sequin replication, Powersync sync) +- Test search functionality (Meilisearch indexing) + +### Backward Compatibility Tests +- Ensure existing SupabaseProjects without CDC specs continue working +- Verify no breaking changes to existing API + +### Upgrade Tests +- Operator upgrade with existing SupabaseProjects +- Verify secrets not regenerated +- Verify no service disruption + +## Next Steps + +1. ✅ **Design complete** - Document written +2. ⏳ **Task #1 research** - CDC permissions SQL ordering +3. **Write implementation plan** - Break down Phase 1 into concrete tasks +4. **Prototype Phase 1** - CRD + basic Sequin on new project +5. **Iterate based on feedback** - Adjust design as implementation progresses + +## References + +- **flicknote-deploy repository:** `/Users/neil/Code/guion/flicknote-deploy` + - Sequin chart: `charts/sequin/` + - Powersync chart: `charts/powersync/` + - Meilisearch config: `flux/apps/base/meilisearch/` + +- **Current cloudnative-supabase:** + - CRD: `api/v1alpha1/supabaseproject_types.go` + - Controller: `internal/controller/supabaseproject_controller.go` + - Resource builders: `internal/resources/` + +- **External documentation:** + - [CNPG Documentation](https://cloudnative-pg.io/) + - [Sequin Documentation](https://sequinstream.com/docs) + - [Powersync Documentation](https://docs.powersync.com/) + - [Meilisearch Documentation](https://www.meilisearch.com/docs) From 51250021975ad05b7822d2134d01213556305888 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 7 Feb 2026 18:59:50 +0800 Subject: [PATCH 02/21] docs(design): resolve CDC permissions SQL with dbmate migration Job Solution: Use dbmate migration Job (mirrors flicknote-deploy pattern) - ConfigMap with single migration (cdc_grants.sql) - Job runs after CNPG cluster ready (waits for auth.users table) - Custom migrations table (cloudnative_supabase_schema_migrations) - Idempotent via dbmate tracking Resolves open question from initial design. Reference: flick-backend-31/tanka/charts/db-init --- ...-07-sequin-powersync-meilisearch-design.md | 75 +++++++++++-------- 1 file changed, 44 insertions(+), 31 deletions(-) diff --git a/docs/plans/2026-02-07-sequin-powersync-meilisearch-design.md b/docs/plans/2026-02-07-sequin-powersync-meilisearch-design.md index 6e4c4d3..c949e24 100644 --- a/docs/plans/2026-02-07-sequin-powersync-meilisearch-design.md +++ b/docs/plans/2026-02-07-sequin-powersync-meilisearch-design.md @@ -340,25 +340,44 @@ publications: ### CDC Permissions SQL -**Status:** Research needed (Task #1) +**Solution:** dbmate migration Job (mirrors flicknote-deploy pattern) -The following SQL grants CDC roles appropriate permissions: +The operator creates a Kubernetes Job that runs dbmate to apply CDC permissions after CNPG cluster is ready. +**Migration file** (`20260207000001_cdc_grants.sql`): ```sql +-- migrate:up + -- Grant CDC role (sequin_replication) read access to public schema only -- More restrictive than pg_read_all_data (avoids system schema access) +-- Grant schema usage GRANT USAGE ON SCHEMA public TO sequin_replication; + +-- Grant sequin CREATE ON DATABASE so its migrations can run CREATE SCHEMA IF NOT EXISTS +-- (supabase_admin owns the database, so it can grant this directly) GRANT CREATE ON DATABASE supabase TO sequin; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public - GRANT SELECT ON TABLES TO sequin_replication; + +-- Grant SELECT on future tables created by supabase_admin in public schema +-- (tables are created by subsequent migrations, so default privileges cover all) +ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT SELECT ON TABLES TO sequin_replication; + +-- migrate:down + +REVOKE USAGE ON SCHEMA public FROM sequin_replication; +ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE SELECT ON TABLES FROM sequin_replication; +REVOKE CREATE ON DATABASE supabase FROM sequin; ``` -**Open question:** When/how to run this SQL? -- Option A: Include in initSQL ConfigMap (runs during cluster bootstrap) -- Option B: Separate Job after cluster + roles ready (flicknote-deploy pattern) +**Implementation:** +- ConfigMap containing migration file +- Job with dbmate image (`ghcr.io/amacneil/dbmate:2.24`) +- Uses `--migrations-table=cloudnative_supabase_schema_migrations` to avoid conflicts with application's schema_migrations table +- Runs `dbmate up` with `--no-dump-schema` flag +- InitContainer waits for CNPG cluster ready (checks for auth.users table existence) +- Status tracked via `ConditionTypeCDCReady` condition -See Task #1 for research on CNPG bootstrap ordering. +Reference: `/Users/neil/Code/guion/flick-backend-31/tanka/charts/db-init` ### Secret Generation @@ -1042,34 +1061,28 @@ spec: ## Open Questions & Research Tasks -### Task #1: CDC Permissions SQL Ordering - -**Status:** Research in progress +### ~~Task #1: CDC Permissions SQL Ordering~~ ✅ RESOLVED -**Question:** When/how should CDC permissions SQL execute? - -```sql -GRANT USAGE ON SCHEMA public TO sequin_replication; -GRANT CREATE ON DATABASE supabase TO sequin; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public - GRANT SELECT ON TABLES TO sequin_replication; -``` +**Status:** ✅ Resolved - using dbmate migration Job -**Options:** -1. **InitSQL ConfigMap** - Include in `bootstrap.initdb.postInitApplicationSQL` - - Issue: Does initSQL run before or after managed roles are created? - - If before: GRANT fails (roles don't exist yet) +**Solution:** Create a Kubernetes Job that runs dbmate to apply CDC permissions after CNPG cluster is ready. -2. **Separate Job** - Run after CNPG cluster + roles ready (flicknote-deploy pattern) - - Pros: Clear ordering, matches existing pattern - - Cons: Adds complexity, need status tracking +**Implementation details:** +- ConfigMap with single migration: `20260207000001_cdc_grants.sql` +- Job uses `ghcr.io/amacneil/dbmate:2.24` image +- Custom migrations table: `--migrations-table=cloudnative_supabase_schema_migrations` (avoids conflict with application migrations) +- InitContainer waits for auth.users table (ensures CNPG fully ready) +- Job runs `dbmate up --no-dump-schema` +- Idempotent: dbmate tracks applied migrations, safe to re-run -3. **CNPG postInitTemplateSQL** - Check if CNPG supports post-role-creation hooks +**Why this approach:** +- ✅ Clear ordering: Runs after CNPG cluster + managed roles ready +- ✅ Mirrors flicknote-deploy pattern (proven in production) +- ✅ Idempotent via dbmate's schema_migrations tracking +- ✅ Status tracking via ConditionTypeCDCReady +- ✅ No conflict with application's dbmate migrations (separate table) -**Research needed:** -- CNPG bootstrap execution order documentation -- Test initSQL vs managed.roles timing -- Idempotency pattern for Job approach +Reference implementation: `/Users/neil/Code/guion/flick-backend-31/tanka/charts/db-init` ### Other Open Questions From 8c226260ff830220bea064697061e189df463756 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 7 Feb 2026 19:40:44 +0800 Subject: [PATCH 03/21] feat(sequin): add Phase 1 Sequin integration - CRD types, deployment, and CDC setup Add optional SequinSpec, PowersyncSpec, and MeilisearchSpec to SupabaseProject CRD. Implement Sequin secret generation (secretKeyBase, vaultKey, apiToken + DB role passwords), deployment builder with external Redis support, and CDC permissions Job using postgres psql for idempotent database creation and grant management. - CRD: Add SequinSpec, PowersyncSpec, MeilisearchSpec with ImageSpec, RedisSpec - Secrets: Auto-generate Sequin app secret + sequin/sequin_replication role passwords - Deployment: Sequin deployment with health probes, env from secrets, external Redis - Service: ClusterIP service with HTTP (7376) and metrics (4000) ports - CDC Job: Creates sequin database + applies replication grants after DB ready - CNPG: Extend cluster with sequin/sequin_replication managed roles when enabled - Controller: Add reconcileSequin, reconcileCDCPermissions, reconcileSequinSecrets - RBAC: Add batch/v1 Jobs permissions, controller owns Job resources - Status: Add SequinReady, CDCReady conditions and Sequin service status Co-Authored-By: Claude Opus 4.6 --- api/v1alpha1/supabaseproject_types.go | 237 ++++++++ api/v1alpha1/zz_generated.deepcopy.go | 223 ++++++++ .../supabase.guion.dev_supabaseprojects.yaml | 531 ++++++++++++++++++ config/rbac/role.yaml | 12 + go.mod | 4 +- .../controller/supabaseproject_controller.go | 180 ++++++ internal/resources/cnpg/cluster.go | 37 +- internal/resources/defaults/images.go | 14 + internal/resources/deployments/sequin.go | 245 ++++++++ internal/resources/jobs/cdc_permissions.go | 205 +++++++ internal/resources/secrets/secrets.go | 72 +++ internal/resources/services/services.go | 29 + 12 files changed, 1786 insertions(+), 3 deletions(-) create mode 100644 internal/resources/deployments/sequin.go create mode 100644 internal/resources/jobs/cdc_permissions.go diff --git a/api/v1alpha1/supabaseproject_types.go b/api/v1alpha1/supabaseproject_types.go index f16a32e..6074667 100644 --- a/api/v1alpha1/supabaseproject_types.go +++ b/api/v1alpha1/supabaseproject_types.go @@ -89,6 +89,18 @@ const ( // ConditionTypeRecoveryReady indicates recovery infrastructure is ready ConditionTypeRecoveryReady = "RecoveryReady" + + // ConditionTypeCDCReady indicates CDC permissions have been applied + ConditionTypeCDCReady = "CDCReady" + + // ConditionTypeSequinReady indicates Sequin is ready + ConditionTypeSequinReady = "SequinReady" + + // ConditionTypePowersyncReady indicates Powersync is ready + ConditionTypePowersyncReady = "PowersyncReady" + + // ConditionTypeMeilisearchReady indicates Meilisearch is ready + ConditionTypeMeilisearchReady = "MeilisearchReady" ) // SupabaseProjectSpec defines the desired state of SupabaseProject @@ -127,6 +139,18 @@ type SupabaseProjectSpec struct { // +optional Kong KongSpec `json:"kong,omitempty"` + // Sequin CDC/event streaming configuration (optional - presence enables Sequin) + // +optional + Sequin *SequinSpec `json:"sequin,omitempty"` + + // Powersync offline-first sync configuration (optional - presence enables Powersync) + // +optional + Powersync *PowersyncSpec `json:"powersync,omitempty"` + + // Meilisearch full-text search configuration (optional - presence enables Meilisearch) + // +optional + Meilisearch *MeilisearchSpec `json:"meilisearch,omitempty"` + // ImagePullSecrets for all deployments // +optional ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"` @@ -521,6 +545,199 @@ type IngressSpec struct { Annotations map[string]string `json:"annotations,omitempty"` } +// ImageSpec defines container image configuration for optional services +type ImageSpec struct { + // Registry (default: docker.io) + // +optional + Registry string `json:"registry,omitempty"` + + // Repository (e.g., sequin/sequin) + // +optional + Repository string `json:"repository,omitempty"` + + // Tag (pinned stable version per service) + // +optional + Tag string `json:"tag,omitempty"` + + // PullPolicy (default: IfNotPresent) + // +kubebuilder:default=IfNotPresent + // +optional + PullPolicy corev1.PullPolicy `json:"pullPolicy,omitempty"` +} + +// SequinSpec defines Sequin CDC/event streaming configuration +type SequinSpec struct { + // Image configuration (default: sequin/sequin:v0.13.25) + // +optional + Image ImageSpec `json:"image,omitempty"` + + // Replicas (default: 1) + // +kubebuilder:default=1 + // +optional + Replicas int32 `json:"replicas,omitempty"` + + // Resources for Sequin pods + // +optional + Resources corev1.ResourceRequirements `json:"resources,omitempty"` + + // Redis configuration - external reference required for Phase 1 + // +optional + Redis RedisSpec `json:"redis,omitempty"` + + // Account/user configuration + // +optional + Account *SequinAccountSpec `json:"account,omitempty"` +} + +// RedisSpec defines Redis configuration for Sequin +type RedisSpec struct { + // External Redis reference (required for Phase 1) + // +optional + External *ExternalRedisSpec `json:"external,omitempty"` +} + +// ExternalRedisSpec defines connection to an external Redis instance +type ExternalRedisSpec struct { + // Host of external Redis instance + // +required + Host string `json:"host"` + + // Port (default: 6379) + // +kubebuilder:default=6379 + // +optional + Port int32 `json:"port,omitempty"` + + // PasswordSecretRef for Redis AUTH (optional) + // +optional + PasswordSecretRef string `json:"passwordSecretRef,omitempty"` +} + +// SequinAccountSpec defines Sequin account/user configuration +type SequinAccountSpec struct { + // Account name (default: "default") + // +kubebuilder:default="default" + // +optional + Name string `json:"name,omitempty"` + + // Admin user email (default: "admin@example.com") + // +optional + Email string `json:"email,omitempty"` +} + +// PowersyncSpec defines Powersync offline-first sync configuration +type PowersyncSpec struct { + // Image configuration (default: journeyapps/powersync-service:1.18.2) + // +optional + Image ImageSpec `json:"image,omitempty"` + + // API deployment configuration (client-facing) + // +optional + API PowersyncAPISpec `json:"api,omitempty"` + + // Replication deployment configuration (CDC processing) + // +optional + Replication PowersyncReplicationSpec `json:"replication,omitempty"` + + // Sync rules configuration + // +optional + SyncRules SyncRulesSpec `json:"syncRules,omitempty"` + + // Compact CronJob configuration + // +optional + Compact PowersyncCompactSpec `json:"compact,omitempty"` +} + +// PowersyncAPISpec defines Powersync API deployment configuration +type PowersyncAPISpec struct { + // Replicas (default: 2) + // +kubebuilder:default=2 + // +optional + Replicas int32 `json:"replicas,omitempty"` + + // Resources for Powersync API pods + // +optional + Resources corev1.ResourceRequirements `json:"resources,omitempty"` + + // NodeOptions for heap size (default: "--max-old-space-size=330") + // +optional + NodeOptions string `json:"nodeOptions,omitempty"` +} + +// PowersyncReplicationSpec defines Powersync replication deployment configuration +type PowersyncReplicationSpec struct { + // Resources for Powersync replication pods + // +optional + Resources corev1.ResourceRequirements `json:"resources,omitempty"` + + // NodeOptions for heap size (default: "--max-old-space-size=482") + // +optional + NodeOptions string `json:"nodeOptions,omitempty"` +} + +// SyncRulesSpec defines sync rules configuration for Powersync +type SyncRulesSpec struct { + // Inline sync rules (YAML string) + // +optional + Inline string `json:"inline,omitempty"` + + // Reference to external ConfigMap containing sync rules (takes precedence over Inline) + // +optional + ConfigMapRef string `json:"configMapRef,omitempty"` +} + +// PowersyncCompactSpec defines Powersync compaction CronJob configuration +type PowersyncCompactSpec struct { + // Enabled (default: true) + // +kubebuilder:default=true + // +optional + Enabled bool `json:"enabled"` + + // Schedule in cron format (default: "0 3 * * *" = 3am daily) + // +kubebuilder:default="0 3 * * *" + // +optional + Schedule string `json:"schedule,omitempty"` + + // Resources for compaction pods + // +optional + Resources corev1.ResourceRequirements `json:"resources,omitempty"` +} + +// MeilisearchSpec defines Meilisearch full-text search configuration +type MeilisearchSpec struct { + // Image configuration (default: getmeili/meilisearch:v1.11.0) + // +optional + Image ImageSpec `json:"image,omitempty"` + + // Replicas (default: 1) + // +kubebuilder:default=1 + // +optional + Replicas int32 `json:"replicas,omitempty"` + + // Resources for Meilisearch pods + // +optional + Resources corev1.ResourceRequirements `json:"resources,omitempty"` + + // Persistence configuration + // +optional + Persistence PersistenceSpec `json:"persistence,omitempty"` + + // MasterKeySecretRef for existing secret (optional, auto-generated if not provided) + // +optional + MasterKeySecretRef string `json:"masterKeySecretRef,omitempty"` +} + +// PersistenceSpec defines persistent storage configuration +type PersistenceSpec struct { + // StorageClass (default: "" = cluster default) + // +optional + StorageClass string `json:"storageClass,omitempty"` + + // Size (default: 10Gi) + // +kubebuilder:default="10Gi" + // +optional + Size string `json:"size,omitempty"` +} + // SupabaseProjectStatus defines the observed state of SupabaseProject type SupabaseProjectStatus struct { // Phase represents the current lifecycle phase @@ -585,6 +802,14 @@ type ServicesStatus struct { Meta ServiceStatus `json:"meta,omitempty"` // +optional Kong ServiceStatus `json:"kong,omitempty"` + // +optional + Sequin ServiceStatus `json:"sequin,omitempty"` + // +optional + PowersyncAPI ServiceStatus `json:"powersyncApi,omitempty"` + // +optional + PowersyncReplication ServiceStatus `json:"powersyncReplication,omitempty"` + // +optional + Meilisearch ServiceStatus `json:"meilisearch,omitempty"` } // ServiceStatus defines individual service status @@ -614,6 +839,18 @@ type SecretNamesStatus struct { // AuthAdmin is the name of the supabase_auth_admin password secret // +optional AuthAdmin string `json:"authAdmin,omitempty"` + + // Sequin is the name of the Sequin secret (secretKeyBase, vaultKey, apiToken) + // +optional + Sequin string `json:"sequin,omitempty"` + + // SequinPassword is the name of the sequin database role password secret + // +optional + SequinPassword string `json:"sequinPassword,omitempty"` + + // SequinReplicationPassword is the name of the sequin_replication role password secret + // +optional + SequinReplicationPassword string `json:"sequinReplicationPassword,omitempty"` } // EndpointsStatus contains service endpoints diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 72d2f4c..5bb4e8a 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -205,6 +205,21 @@ func (in *EndpointsStatus) DeepCopy() *EndpointsStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalRedisSpec) DeepCopyInto(out *ExternalRedisSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalRedisSpec. +func (in *ExternalRedisSpec) DeepCopy() *ExternalRedisSpec { + if in == nil { + return nil + } + out := new(ExternalRedisSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GoogleProviderSpec) DeepCopyInto(out *GoogleProviderSpec) { *out = *in @@ -220,6 +235,21 @@ func (in *GoogleProviderSpec) DeepCopy() *GoogleProviderSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ImageSpec) DeepCopyInto(out *ImageSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageSpec. +func (in *ImageSpec) DeepCopy() *ImageSpec { + if in == nil { + return nil + } + out := new(ImageSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IngressSpec) DeepCopyInto(out *IngressSpec) { *out = *in @@ -278,6 +308,24 @@ func (in *KongSpec) DeepCopy() *KongSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MeilisearchSpec) DeepCopyInto(out *MeilisearchSpec) { + *out = *in + out.Image = in.Image + in.Resources.DeepCopyInto(&out.Resources) + out.Persistence = in.Persistence +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MeilisearchSpec. +func (in *MeilisearchSpec) DeepCopy() *MeilisearchSpec { + if in == nil { + return nil + } + out := new(MeilisearchSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MetaSpec) DeepCopyInto(out *MetaSpec) { *out = *in @@ -294,6 +342,89 @@ func (in *MetaSpec) DeepCopy() *MetaSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PersistenceSpec) DeepCopyInto(out *PersistenceSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PersistenceSpec. +func (in *PersistenceSpec) DeepCopy() *PersistenceSpec { + if in == nil { + return nil + } + out := new(PersistenceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PowersyncAPISpec) DeepCopyInto(out *PowersyncAPISpec) { + *out = *in + in.Resources.DeepCopyInto(&out.Resources) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PowersyncAPISpec. +func (in *PowersyncAPISpec) DeepCopy() *PowersyncAPISpec { + if in == nil { + return nil + } + out := new(PowersyncAPISpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PowersyncCompactSpec) DeepCopyInto(out *PowersyncCompactSpec) { + *out = *in + in.Resources.DeepCopyInto(&out.Resources) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PowersyncCompactSpec. +func (in *PowersyncCompactSpec) DeepCopy() *PowersyncCompactSpec { + if in == nil { + return nil + } + out := new(PowersyncCompactSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PowersyncReplicationSpec) DeepCopyInto(out *PowersyncReplicationSpec) { + *out = *in + in.Resources.DeepCopyInto(&out.Resources) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PowersyncReplicationSpec. +func (in *PowersyncReplicationSpec) DeepCopy() *PowersyncReplicationSpec { + if in == nil { + return nil + } + out := new(PowersyncReplicationSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PowersyncSpec) DeepCopyInto(out *PowersyncSpec) { + *out = *in + out.Image = in.Image + in.API.DeepCopyInto(&out.API) + in.Replication.DeepCopyInto(&out.Replication) + out.SyncRules = in.SyncRules + in.Compact.DeepCopyInto(&out.Compact) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PowersyncSpec. +func (in *PowersyncSpec) DeepCopy() *PowersyncSpec { + if in == nil { + return nil + } + out := new(PowersyncSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RecoverySpec) DeepCopyInto(out *RecoverySpec) { *out = *in @@ -310,6 +441,26 @@ func (in *RecoverySpec) DeepCopy() *RecoverySpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RedisSpec) DeepCopyInto(out *RedisSpec) { + *out = *in + if in.External != nil { + in, out := &in.External, &out.External + *out = new(ExternalRedisSpec) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisSpec. +func (in *RedisSpec) DeepCopy() *RedisSpec { + if in == nil { + return nil + } + out := new(RedisSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RestSpec) DeepCopyInto(out *RestSpec) { *out = *in @@ -391,6 +542,44 @@ func (in *SecretsSpec) DeepCopy() *SecretsSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SequinAccountSpec) DeepCopyInto(out *SequinAccountSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SequinAccountSpec. +func (in *SequinAccountSpec) DeepCopy() *SequinAccountSpec { + if in == nil { + return nil + } + out := new(SequinAccountSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SequinSpec) DeepCopyInto(out *SequinSpec) { + *out = *in + out.Image = in.Image + in.Resources.DeepCopyInto(&out.Resources) + in.Redis.DeepCopyInto(&out.Redis) + if in.Account != nil { + in, out := &in.Account, &out.Account + *out = new(SequinAccountSpec) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SequinSpec. +func (in *SequinSpec) DeepCopy() *SequinSpec { + if in == nil { + return nil + } + out := new(SequinSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServiceStatus) DeepCopyInto(out *ServiceStatus) { *out = *in @@ -414,6 +603,10 @@ func (in *ServicesStatus) DeepCopyInto(out *ServicesStatus) { out.Studio = in.Studio out.Meta = in.Meta out.Kong = in.Kong + out.Sequin = in.Sequin + out.PowersyncAPI = in.PowersyncAPI + out.PowersyncReplication = in.PowersyncReplication + out.Meilisearch = in.Meilisearch } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServicesStatus. @@ -520,6 +713,21 @@ func (in *SupabaseProjectSpec) DeepCopyInto(out *SupabaseProjectSpec) { in.Studio.DeepCopyInto(&out.Studio) in.Meta.DeepCopyInto(&out.Meta) in.Kong.DeepCopyInto(&out.Kong) + if in.Sequin != nil { + in, out := &in.Sequin, &out.Sequin + *out = new(SequinSpec) + (*in).DeepCopyInto(*out) + } + if in.Powersync != nil { + in, out := &in.Powersync, &out.Powersync + *out = new(PowersyncSpec) + (*in).DeepCopyInto(*out) + } + if in.Meilisearch != nil { + in, out := &in.Meilisearch, &out.Meilisearch + *out = new(MeilisearchSpec) + (*in).DeepCopyInto(*out) + } if in.ImagePullSecrets != nil { in, out := &in.ImagePullSecrets, &out.ImagePullSecrets *out = make([]v1.LocalObjectReference, len(*in)) @@ -562,3 +770,18 @@ func (in *SupabaseProjectStatus) DeepCopy() *SupabaseProjectStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SyncRulesSpec) DeepCopyInto(out *SyncRulesSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SyncRulesSpec. +func (in *SyncRulesSpec) DeepCopy() *SyncRulesSpec { + if in == nil { + return nil + } + out := new(SyncRulesSpec) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml b/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml index acc3698..c9e7a87 100644 --- a/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml +++ b/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml @@ -878,6 +878,107 @@ spec: type: object type: object type: object + meilisearch: + description: Meilisearch full-text search configuration (optional + - presence enables Meilisearch) + properties: + image: + description: 'Image configuration (default: getmeili/meilisearch:v1.11.0)' + properties: + pullPolicy: + default: IfNotPresent + description: 'PullPolicy (default: IfNotPresent)' + type: string + registry: + description: 'Registry (default: docker.io)' + type: string + repository: + description: Repository (e.g., sequin/sequin) + type: string + tag: + description: Tag (pinned stable version per service) + type: string + type: object + masterKeySecretRef: + description: MasterKeySecretRef for existing secret (optional, + auto-generated if not provided) + type: string + persistence: + description: Persistence configuration + properties: + size: + default: 10Gi + description: 'Size (default: 10Gi)' + type: string + storageClass: + description: 'StorageClass (default: "" = cluster default)' + type: string + type: object + replicas: + default: 1 + description: 'Replicas (default: 1)' + format: int32 + type: integer + resources: + description: Resources for Meilisearch pods + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object meta: description: Meta service configuration (postgres-meta) properties: @@ -950,6 +1051,248 @@ spec: type: object type: object type: object + powersync: + description: Powersync offline-first sync configuration (optional + - presence enables Powersync) + properties: + api: + description: API deployment configuration (client-facing) + properties: + nodeOptions: + description: 'NodeOptions for heap size (default: "--max-old-space-size=330")' + type: string + replicas: + default: 2 + description: 'Replicas (default: 2)' + format: int32 + type: integer + resources: + description: Resources for Powersync API pods + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object + compact: + description: Compact CronJob configuration + properties: + enabled: + default: true + description: 'Enabled (default: true)' + type: boolean + resources: + description: Resources for compaction pods + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + schedule: + default: 0 3 * * * + description: 'Schedule in cron format (default: "0 3 * * *" + = 3am daily)' + type: string + type: object + image: + description: 'Image configuration (default: journeyapps/powersync-service:1.18.2)' + properties: + pullPolicy: + default: IfNotPresent + description: 'PullPolicy (default: IfNotPresent)' + type: string + registry: + description: 'Registry (default: docker.io)' + type: string + repository: + description: Repository (e.g., sequin/sequin) + type: string + tag: + description: Tag (pinned stable version per service) + type: string + type: object + replication: + description: Replication deployment configuration (CDC processing) + properties: + nodeOptions: + description: 'NodeOptions for heap size (default: "--max-old-space-size=482")' + type: string + resources: + description: Resources for Powersync replication pods + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object + syncRules: + description: Sync rules configuration + properties: + configMapRef: + description: Reference to external ConfigMap containing sync + rules (takes precedence over Inline) + type: string + inline: + description: Inline sync rules (YAML string) + type: string + type: object + type: object rest: description: Rest service configuration (PostgREST) properties: @@ -1071,6 +1414,126 @@ spec: rule: self.autoGenerate || (self.jwt.size() > 0 && self.supabaseAdmin.size() > 0 && self.authenticator.size() > 0 && self.authAdmin.size() > 0) + sequin: + description: Sequin CDC/event streaming configuration (optional - + presence enables Sequin) + properties: + account: + description: Account/user configuration + properties: + email: + description: 'Admin user email (default: "admin@example.com")' + type: string + name: + default: default + description: 'Account name (default: "default")' + type: string + type: object + image: + description: 'Image configuration (default: sequin/sequin:v0.13.25)' + properties: + pullPolicy: + default: IfNotPresent + description: 'PullPolicy (default: IfNotPresent)' + type: string + registry: + description: 'Registry (default: docker.io)' + type: string + repository: + description: Repository (e.g., sequin/sequin) + type: string + tag: + description: Tag (pinned stable version per service) + type: string + type: object + redis: + description: Redis configuration - external reference required + for Phase 1 + properties: + external: + description: External Redis reference (required for Phase + 1) + properties: + host: + description: Host of external Redis instance + type: string + passwordSecretRef: + description: PasswordSecretRef for Redis AUTH (optional) + type: string + port: + default: 6379 + description: 'Port (default: 6379)' + format: int32 + type: integer + required: + - host + type: object + type: object + replicas: + default: 1 + description: 'Replicas (default: 1)' + format: int32 + type: integer + resources: + description: Resources for Sequin pods + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object studio: description: Studio dashboard configuration properties: @@ -1277,6 +1740,18 @@ spec: jwt: description: JWT is the name of the JWT secret type: string + sequin: + description: Sequin is the name of the Sequin secret (secretKeyBase, + vaultKey, apiToken) + type: string + sequinPassword: + description: SequinPassword is the name of the sequin database + role password secret + type: string + sequinReplicationPassword: + description: SequinReplicationPassword is the name of the sequin_replication + role password secret + type: string supabaseAdmin: description: SupabaseAdmin is the name of the supabase_admin password secret @@ -1313,6 +1788,20 @@ spec: required: - ready type: object + meilisearch: + description: ServiceStatus defines individual service status + properties: + availableReplicas: + description: AvailableReplicas is the number of available + replicas + format: int32 + type: integer + ready: + description: Ready indicates if the service is ready + type: boolean + required: + - ready + type: object meta: description: ServiceStatus defines individual service status properties: @@ -1327,6 +1816,34 @@ spec: required: - ready type: object + powersyncApi: + description: ServiceStatus defines individual service status + properties: + availableReplicas: + description: AvailableReplicas is the number of available + replicas + format: int32 + type: integer + ready: + description: Ready indicates if the service is ready + type: boolean + required: + - ready + type: object + powersyncReplication: + description: ServiceStatus defines individual service status + properties: + availableReplicas: + description: AvailableReplicas is the number of available + replicas + format: int32 + type: integer + ready: + description: Ready indicates if the service is ready + type: boolean + required: + - ready + type: object rest: description: ServiceStatus defines individual service status properties: @@ -1341,6 +1858,20 @@ spec: required: - ready type: object + sequin: + description: ServiceStatus defines individual service status + properties: + availableReplicas: + description: AvailableReplicas is the number of available + replicas + format: int32 + type: integer + ready: + description: Ready indicates if the service is ready + type: boolean + required: + - ready + type: object studio: description: ServiceStatus defines individual service status properties: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 963b4ec..7dbdf82 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -42,6 +42,18 @@ rules: - patch - update - watch +- apiGroups: + - batch + resources: + - jobs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - postgresql.cnpg.io resources: diff --git a/go.mod b/go.mod index 01bed09..b967c5c 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module github.com/GuionAI/cloudnative-supabase go 1.25.0 require ( + github.com/cloudnative-pg/barman-cloud v0.4.0 github.com/cloudnative-pg/cloudnative-pg v1.28.0 + github.com/cloudnative-pg/machinery v0.3.3 github.com/cloudnative-pg/plugin-barman-cloud v0.10.0 github.com/onsi/ginkgo/v2 v2.27.3 github.com/onsi/gomega v1.38.3 @@ -22,9 +24,7 @@ require ( github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cloudnative-pg/barman-cloud v0.4.0 // indirect github.com/cloudnative-pg/cnpg-i v0.3.1 // indirect - github.com/cloudnative-pg/machinery v0.3.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect diff --git a/internal/controller/supabaseproject_controller.go b/internal/controller/supabaseproject_controller.go index 39bcba2..94f446c 100644 --- a/internal/controller/supabaseproject_controller.go +++ b/internal/controller/supabaseproject_controller.go @@ -24,6 +24,7 @@ import ( cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" barmancloudv1 "github.com/cloudnative-pg/plugin-barman-cloud/api/v1" appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" @@ -39,6 +40,7 @@ import ( "github.com/GuionAI/cloudnative-supabase/internal/resources/cnpg" "github.com/GuionAI/cloudnative-supabase/internal/resources/configmaps" "github.com/GuionAI/cloudnative-supabase/internal/resources/deployments" + "github.com/GuionAI/cloudnative-supabase/internal/resources/jobs" "github.com/GuionAI/cloudnative-supabase/internal/resources/secrets" "github.com/GuionAI/cloudnative-supabase/internal/resources/services" ) @@ -92,6 +94,7 @@ type SupabaseProjectReconciler struct { // +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. @@ -146,6 +149,16 @@ func (r *SupabaseProjectReconciler) Reconcile(ctx context.Context, req ctrl.Requ return ctrl.Result{}, err } + // Phase 6: CDC Services (after core services) + if project.Spec.Sequin != nil { + if err := r.reconcileCDCPermissions(ctx, project); err != nil { + return ctrl.Result{}, err + } + if err := r.reconcileSequin(ctx, project); err != nil { + return ctrl.Result{}, err + } + } + // All phases complete project.Status.Phase = supabasev1alpha1.PhaseRunning project.Status.ObservedGeneration = project.Generation @@ -281,6 +294,14 @@ func (r *SupabaseProjectReconciler) reconcileAutoGeneratedSecrets(ctx context.Co if allExist { log.Info("Secrets already exist in cluster, syncing status") + + // Also sync Sequin secrets if Sequin is enabled + if project.Spec.Sequin != nil { + if err := r.reconcileSequinSecrets(ctx, project, &secretNames); err != nil { + return err + } + } + project.Status.SecretNames = secretNames r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionTrue, "SecretsExist", "All secrets exist") if err := r.Status().Update(ctx, project); err != nil { @@ -314,6 +335,13 @@ func (r *SupabaseProjectReconciler) reconcileAutoGeneratedSecrets(ctx context.Co } } + // Generate Sequin secrets if Sequin is enabled + if project.Spec.Sequin != nil { + if err := r.reconcileSequinSecrets(ctx, project, &secretNames); err != nil { + return err + } + } + // Update status with secret names project.Status.SecretNames = secretNames @@ -325,6 +353,60 @@ func (r *SupabaseProjectReconciler) reconcileAutoGeneratedSecrets(ctx context.Co return nil } +// reconcileSequinSecrets generates Sequin-related secrets if they don't exist +func (r *SupabaseProjectReconciler) reconcileSequinSecrets(ctx context.Context, project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) error { + log := logf.FromContext(ctx) + + sequinName, sequinPwdName, sequinReplPwdName := secrets.SequinSecretNames(project) + + // Check if all Sequin secrets exist + allExist := true + for _, name := range []string{sequinName, sequinPwdName, sequinReplPwdName} { + existing := &corev1.Secret{} + if err := r.Get(ctx, types.NamespacedName{Name: name, Namespace: project.Namespace}, existing); err != nil { + if apierrors.IsNotFound(err) { + allExist = false + break + } + return err + } + } + + if allExist { + log.Info("Sequin secrets already exist, syncing status") + secretNames.Sequin = sequinName + secretNames.SequinPassword = sequinPwdName + secretNames.SequinReplicationPassword = sequinReplPwdName + return nil + } + + // Generate Sequin secrets + log.Info("Generating Sequin secrets") + sequinSecrets, err := secrets.GenerateSequinSecrets(project) + if err != nil { + r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionFalse, "SequinSecretsFailed", err.Error()) + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return statusErr + } + return err + } + + for _, secret := range sequinSecrets { + if err := r.createOrUpdateSecret(ctx, project, secret); err != nil { + r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionFalse, "CreateFailed", err.Error()) + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return statusErr + } + return err + } + } + + secretNames.Sequin = sequinName + secretNames.SequinPassword = sequinPwdName + secretNames.SequinReplicationPassword = sequinReplPwdName + return nil +} + // reconcileInitSQL ensures the init SQL ConfigMap exists func (r *SupabaseProjectReconciler) reconcileInitSQL(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { log := logf.FromContext(ctx) @@ -938,6 +1020,103 @@ func (r *SupabaseProjectReconciler) createOrUpdateScheduledBackup(ctx context.Co return nil } +// reconcileCDCPermissions ensures CDC permissions are applied via dbmate Job +func (r *SupabaseProjectReconciler) reconcileCDCPermissions(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { + log := logf.FromContext(ctx) + log.Info("Reconciling CDC permissions") + + secretNames := &project.Status.SecretNames + + // Create CDC migrations ConfigMap + configMap := jobs.BuildCDCMigrationsConfigMap(project) + if err := r.createOrUpdateConfigMap(ctx, project, configMap); err != nil { + r.setCondition(project, supabasev1alpha1.ConditionTypeCDCReady, metav1.ConditionFalse, "ConfigMapFailed", err.Error()) + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return statusErr + } + return err + } + + // Create or check CDC permissions Job + job := jobs.BuildCDCPermissionsJob(project, secretNames) + if err := r.createOrCheckJob(ctx, project, job); err != nil { + r.setCondition(project, supabasev1alpha1.ConditionTypeCDCReady, metav1.ConditionFalse, "JobFailed", err.Error()) + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return statusErr + } + return err + } + + r.setCondition(project, supabasev1alpha1.ConditionTypeCDCReady, metav1.ConditionTrue, "CDCPermissionsApplied", "CDC permissions Job created") + return nil +} + +// reconcileSequin deploys the Sequin service +func (r *SupabaseProjectReconciler) reconcileSequin(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { + log := logf.FromContext(ctx) + log.Info("Reconciling Sequin service") + + secretNames := &project.Status.SecretNames + + // Create deployment + deployment := deployments.BuildSequinDeployment(project, secretNames) + if err := r.createOrUpdateDeployment(ctx, project, deployment); err != nil { + r.setCondition(project, supabasev1alpha1.ConditionTypeSequinReady, metav1.ConditionFalse, "DeploymentFailed", err.Error()) + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return statusErr + } + return err + } + + // Create service + service := services.BuildSequinService(project) + if err := r.createOrUpdateService(ctx, project, service); err != nil { + r.setCondition(project, supabasev1alpha1.ConditionTypeSequinReady, metav1.ConditionFalse, "ServiceFailed", err.Error()) + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return statusErr + } + return err + } + + project.Status.Services.Sequin = supabasev1alpha1.ServiceStatus{Ready: true} + r.setCondition(project, supabasev1alpha1.ConditionTypeSequinReady, metav1.ConditionTrue, "Ready", "Sequin service is running") + return nil +} + +// createOrCheckJob creates a Job if it doesn't exist, or checks status of existing Job +func (r *SupabaseProjectReconciler) createOrCheckJob(ctx context.Context, project *supabasev1alpha1.SupabaseProject, job *batchv1.Job) error { + log := logf.FromContext(ctx) + + // Set owner reference + if err := controllerutil.SetControllerReference(project, job, r.Scheme); err != nil { + return err + } + + // Check if Job exists + existing := &batchv1.Job{} + err := r.Get(ctx, types.NamespacedName{Name: job.Name, Namespace: job.Namespace}, existing) + if err != nil { + if apierrors.IsNotFound(err) { + log.Info("Creating Job", "name", job.Name) + return r.Create(ctx, job) + } + return err + } + + // Job exists - check completion status + if existing.Status.Succeeded > 0 { + log.V(1).Info("Job completed successfully", "name", job.Name) + return nil + } + if existing.Status.Failed > 0 && existing.Status.Active == 0 { + return fmt.Errorf("Job %s has failed", job.Name) + } + + // Job still running + log.V(1).Info("Job still running", "name", job.Name, "active", existing.Status.Active) + return nil +} + func (r *SupabaseProjectReconciler) setCondition(project *supabasev1alpha1.SupabaseProject, conditionType string, status metav1.ConditionStatus, reason, message string) { condition := metav1.Condition{ Type: conditionType, @@ -957,6 +1136,7 @@ func (r *SupabaseProjectReconciler) SetupWithManager(mgr ctrl.Manager) error { Owns(&corev1.ConfigMap{}). Owns(&corev1.Service{}). Owns(&appsv1.Deployment{}). + Owns(&batchv1.Job{}). Owns(&cnpgv1.Cluster{}). Owns(&cnpgv1.ScheduledBackup{}). Owns(&barmancloudv1.ObjectStore{}). diff --git a/internal/resources/cnpg/cluster.go b/internal/resources/cnpg/cluster.go index 897ccf6..41093f1 100644 --- a/internal/resources/cnpg/cluster.go +++ b/internal/resources/cnpg/cluster.go @@ -92,7 +92,7 @@ func BuildCluster(project *supabasev1alpha1.SupabaseProject, secretNames *supaba StorageConfiguration: spec.Storage, Managed: &cnpgv1.ManagedConfiguration{ - Roles: buildRoles(&spec, secretNames), + Roles: buildAllRoles(project, secretNames), }, }, } @@ -196,6 +196,41 @@ func buildBootstrapConfiguration(project *supabasev1alpha1.SupabaseProject, secr } } +// buildAllRoles combines base Supabase roles with optional Sequin roles +func buildAllRoles(project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) []cnpgv1.RoleConfiguration { + roles := buildRoles(&project.Spec.Database, secretNames) + if project.Spec.Sequin != nil && secretNames.SequinPassword != "" { + roles = append(roles, BuildSequinRoles(secretNames)...) + } + return roles +} + +// BuildSequinRoles returns additional CNPG roles required for Sequin CDC +func BuildSequinRoles(secretNames *supabasev1alpha1.SecretNamesStatus) []cnpgv1.RoleConfiguration { + return []cnpgv1.RoleConfiguration{ + { + Name: "sequin", + Ensure: cnpgv1.EnsurePresent, + Login: true, + PasswordSecret: &cnpgv1.LocalObjectReference{ + Name: secretNames.SequinPassword, + }, + Comment: "Sequin database owner role", + }, + { + Name: "sequin_replication", + Ensure: cnpgv1.EnsurePresent, + Login: true, + Replication: true, + BypassRLS: true, + PasswordSecret: &cnpgv1.LocalObjectReference{ + Name: secretNames.SequinReplicationPassword, + }, + Comment: "Sequin CDC replication role", + }, + } +} + // buildRoles creates the managed roles for Supabase func buildRoles(spec *supabasev1alpha1.DatabaseSpec, secretNames *supabasev1alpha1.SecretNamesStatus) []cnpgv1.RoleConfiguration { roles := []cnpgv1.RoleConfiguration{ diff --git a/internal/resources/defaults/images.go b/internal/resources/defaults/images.go index 952efbe..ef30afb 100644 --- a/internal/resources/defaults/images.go +++ b/internal/resources/defaults/images.go @@ -25,4 +25,18 @@ const ( // Kong image defaults KongImage = "kong" KongTag = "2.8.1" + + // Sequin image defaults + SequinImage = "sequin/sequin" + SequinTag = "v0.13.25" + + // Powersync image defaults + PowersyncImage = "journeyapps/powersync-service" + PowersyncTag = "1.18.2" + + // Meilisearch image defaults + MeilisearchImage = "getmeili/meilisearch" + MeilisearchTag = "v1.11.0" + + // Note: CDC permissions Job uses the same PostgresImage for psql compatibility ) diff --git a/internal/resources/deployments/sequin.go b/internal/resources/deployments/sequin.go new file mode 100644 index 0000000..21f0922 --- /dev/null +++ b/internal/resources/deployments/sequin.go @@ -0,0 +1,245 @@ +/* +Copyright 2026 GuionAI. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package deployments + +import ( + "fmt" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" + "github.com/GuionAI/cloudnative-supabase/internal/resources/cnpg" + "github.com/GuionAI/cloudnative-supabase/internal/resources/common" + "github.com/GuionAI/cloudnative-supabase/internal/resources/defaults" +) + +const ( + SequinComponentName = "sequin" + SequinHTTPPort = 7376 + SequinMetricsPort = 4000 +) + +// SequinDeploymentName returns the Sequin deployment name +func SequinDeploymentName(project *supabasev1alpha1.SupabaseProject) string { + return project.Name + "-sequin" +} + +// ResolveImage resolves an ImageSpec to a full image string with defaults +func ResolveImage(spec supabasev1alpha1.ImageSpec, defaultImage, defaultTag string) string { + repo := defaultImage + if spec.Repository != "" { + repo = spec.Repository + } + tag := defaultTag + if spec.Tag != "" { + tag = spec.Tag + } + image := fmt.Sprintf("%s:%s", repo, tag) + if spec.Registry != "" { + image = fmt.Sprintf("%s/%s", spec.Registry, image) + } + return image +} + +// ResolvePullPolicy resolves pull policy from ImageSpec with IfNotPresent default +func ResolvePullPolicy(spec supabasev1alpha1.ImageSpec) corev1.PullPolicy { + if spec.PullPolicy != "" { + return spec.PullPolicy + } + return corev1.PullIfNotPresent +} + +// DefaultSequinResources returns default resource requirements for Sequin +func DefaultSequinResources() corev1.ResourceRequirements { + return corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("256Mi"), + corev1.ResourceCPU: resource.MustParse("100m"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("512Mi"), + corev1.ResourceCPU: resource.MustParse("500m"), + }, + } +} + +// NormalizeSequinResources returns provided resources if set, otherwise returns defaults +func NormalizeSequinResources(resources corev1.ResourceRequirements) corev1.ResourceRequirements { + if len(resources.Requests) == 0 && len(resources.Limits) == 0 { + return DefaultSequinResources() + } + return resources +} + +// BuildSequinDeployment creates the Sequin deployment +func BuildSequinDeployment(project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) *appsv1.Deployment { + spec := project.Spec.Sequin + name := SequinDeploymentName(project) + dbHost := cnpg.ClusterRWServiceName(project) + + image := ResolveImage(spec.Image, defaults.SequinImage, defaults.SequinTag) + pullPolicy := ResolvePullPolicy(spec.Image) + replicas := NormalizeReplicas(spec.Replicas) + resources := NormalizeSequinResources(spec.Resources) + + env := buildSequinEnv(project, secretNames, dbHost) + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: project.Namespace, + Labels: common.ComponentLabels(project, SequinComponentName), + Annotations: common.ReloaderAnnotations(), + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: common.SelectorLabels(project, SequinComponentName), + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: common.ComponentLabels(project, SequinComponentName), + Annotations: common.ReloaderAnnotations(), + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: SequinComponentName, + Image: image, + ImagePullPolicy: pullPolicy, + Env: env, + Ports: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: SequinHTTPPort, + Protocol: corev1.ProtocolTCP, + }, + { + Name: "metrics", + ContainerPort: SequinMetricsPort, + Protocol: corev1.ProtocolTCP, + }, + }, + LivenessProbe: BuildHTTPProbe(ProbeConfig{ + Path: "/health", + Port: SequinHTTPPort, + InitialDelaySeconds: 30, + PeriodSeconds: 10, + TimeoutSeconds: 5, + }), + ReadinessProbe: BuildHTTPProbe(ProbeConfig{ + Path: "/health", + Port: SequinHTTPPort, + InitialDelaySeconds: 10, + PeriodSeconds: 5, + TimeoutSeconds: 3, + }), + Resources: resources, + }, + }, + }, + }, + }, + } + + AddImagePullSecrets(&deployment.Spec.Template.Spec, project) + + return deployment +} + +// buildSequinEnv builds environment variables for the Sequin deployment +func buildSequinEnv(project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus, dbHost string) []corev1.EnvVar { + spec := project.Spec.Sequin + + // Build Redis URL + redisURL := "redis://localhost:6379" + if spec.Redis.External != nil { + port := spec.Redis.External.Port + if port == 0 { + port = 6379 + } + redisURL = fmt.Sprintf("redis://%s:%d", spec.Redis.External.Host, port) + } + + env := []corev1.EnvVar{ + // Sequin database connection (sequin's own database) + {Name: "PG_HOSTNAME", Value: dbHost}, + {Name: "PG_PORT", Value: "5432"}, + {Name: "PG_DATABASE", Value: "sequin"}, + { + Name: "PG_USERNAME", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secretNames.SequinPassword, + }, + Key: "username", + }, + }, + }, + { + Name: "PG_PASSWORD", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secretNames.SequinPassword, + }, + Key: "password", + }, + }, + }, + + // Redis + {Name: "REDIS_URL", Value: redisURL}, + + // Sequin configuration + {Name: "SEQUIN_ENV", Value: "prod"}, + {Name: "PHX_HOST", Value: SequinDeploymentName(project)}, + {Name: "PORT", Value: fmt.Sprintf("%d", SequinHTTPPort)}, + + // Secret key base + { + Name: "SECRET_KEY_BASE", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secretNames.Sequin, + }, + Key: "secretKeyBase", + }, + }, + }, + + // Vault key + { + Name: "VAULT_KEY", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secretNames.Sequin, + }, + Key: "vaultKey", + }, + }, + }, + } + + return env +} diff --git a/internal/resources/jobs/cdc_permissions.go b/internal/resources/jobs/cdc_permissions.go new file mode 100644 index 0000000..fa07155 --- /dev/null +++ b/internal/resources/jobs/cdc_permissions.go @@ -0,0 +1,205 @@ +/* +Copyright 2026 GuionAI. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package jobs + +import ( + "fmt" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" + "github.com/GuionAI/cloudnative-supabase/internal/resources/cnpg" + "github.com/GuionAI/cloudnative-supabase/internal/resources/common" + "github.com/GuionAI/cloudnative-supabase/internal/resources/defaults" +) + +const ( + CDCComponentName = "cdc-permissions" +) + +// CDCConfigMapName returns the name of the CDC migrations ConfigMap +func CDCConfigMapName(project *supabasev1alpha1.SupabaseProject) string { + return project.Name + "-cdc-migrations" +} + +// CDCJobName returns the name of the CDC permissions Job +func CDCJobName(project *supabasev1alpha1.SupabaseProject) string { + return project.Name + "-cdc-permissions" +} + +// BuildCDCMigrationsConfigMap creates the ConfigMap containing CDC setup scripts +func BuildCDCMigrationsConfigMap(project *supabasev1alpha1.SupabaseProject) *corev1.ConfigMap { + // Shell script that handles both database creation and grants + // Uses psql which handles CREATE DATABASE outside transactions + setupScript := `#!/bin/sh +set -e + +echo "=== CDC Permissions Setup ===" + +# Step 1: Create sequin database if it doesn't exist +echo "Checking if sequin database exists..." +DB_EXISTS=$(psql "$PGCONNSTR" -tAc "SELECT 1 FROM pg_database WHERE datname='sequin'" 2>/dev/null || echo "0") +if [ "$DB_EXISTS" != "1" ]; then + echo "Creating sequin database..." + psql "$PGCONNSTR" -c "CREATE DATABASE sequin OWNER sequin" + echo "Sequin database created" +else + echo "Sequin database already exists" +fi + +# Step 2: Apply CDC grants (idempotent) +echo "Applying CDC grants..." +psql "$PGCONNSTR" <<'EOSQL' +-- Grant CDC role (sequin_replication) read access to public schema +GRANT USAGE ON SCHEMA public TO sequin_replication; + +-- Grant sequin CREATE ON DATABASE for its migrations +GRANT CREATE ON DATABASE supabase TO sequin; + +-- Grant SELECT on future tables created by supabase_admin in public schema +ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT SELECT ON TABLES TO sequin_replication; + +-- Grant SELECT on existing tables in public schema +GRANT SELECT ON ALL TABLES IN SCHEMA public TO sequin_replication; +EOSQL + +echo "=== CDC Permissions Setup Complete ===" +` + + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: CDCConfigMapName(project), + Namespace: project.Namespace, + Labels: common.ComponentLabels(project, CDCComponentName), + }, + Data: map[string]string{ + "setup.sh": setupScript, + }, + } +} + +// BuildCDCPermissionsJob creates the Job that applies CDC permissions after database is ready +func BuildCDCPermissionsJob(project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) *batchv1.Job { + name := CDCJobName(project) + dbHost := cnpg.ClusterRWServiceName(project) + // Use the same postgres image as the CNPG cluster for psql compatibility + pgImage := fmt.Sprintf("%s:%s", defaults.PostgresImage, defaults.PostgresTag) + + var backoffLimit int32 = 3 + + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: project.Namespace, + Labels: common.ComponentLabels(project, CDCComponentName), + }, + Spec: batchv1.JobSpec{ + BackoffLimit: &backoffLimit, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: common.ComponentLabels(project, CDCComponentName), + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyOnFailure, + InitContainers: []corev1.Container{ + { + Name: "wait-for-db", + Image: pgImage, + Command: []string{"sh", "-c"}, + Args: []string{ + fmt.Sprintf( + `echo "Waiting for database to be ready..." +until pg_isready -h %s -p 5432 -U "$PGUSER"; do + echo "Database not ready yet, retrying in 5s..." + sleep 5 +done +echo "Database is ready"`, dbHost), + }, + Env: buildCDCEnv(dbHost, secretNames), + }, + }, + Containers: []corev1.Container{ + { + Name: "cdc-setup", + Image: pgImage, + Command: []string{"sh", "/scripts/setup.sh"}, + Env: buildCDCEnv(dbHost, secretNames), + VolumeMounts: []corev1.VolumeMount{ + { + Name: "scripts", + MountPath: "/scripts", + ReadOnly: true, + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "scripts", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: CDCConfigMapName(project), + }, + DefaultMode: int32Ptr(0755), + }, + }, + }, + }, + }, + }, + }, + } +} + +// buildCDCEnv builds env vars for the CDC Job using supabase_admin credentials +func buildCDCEnv(dbHost string, secretNames *supabasev1alpha1.SecretNamesStatus) []corev1.EnvVar { + return []corev1.EnvVar{ + { + Name: "PGUSER", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secretNames.SupabaseAdmin, + }, + Key: "username", + }, + }, + }, + { + Name: "PGPASSWORD", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secretNames.SupabaseAdmin, + }, + Key: "password", + }, + }, + }, + { + Name: "PGCONNSTR", + Value: fmt.Sprintf("postgres://$(PGUSER):$(PGPASSWORD)@%s:5432/supabase?sslmode=disable", dbHost), + }, + } +} + +func int32Ptr(i int32) *int32 { + return &i +} diff --git a/internal/resources/secrets/secrets.go b/internal/resources/secrets/secrets.go index 3d06ecb..85ac98b 100644 --- a/internal/resources/secrets/secrets.go +++ b/internal/resources/secrets/secrets.go @@ -172,3 +172,75 @@ func GetSecretNamesFromSpec(spec *supabasev1alpha1.SecretsSpec) supabasev1alpha1 AuthAdmin: spec.AuthAdmin, } } + +// GenerateSequinSecrets generates all Sequin-related secrets +func GenerateSequinSecrets(project *supabasev1alpha1.SupabaseProject) ([]*corev1.Secret, error) { + var secrets []*corev1.Secret + + // Sequin application secret (secretKeyBase, vaultKey, apiToken) + appSecret, err := generateSequinAppSecret(project) + if err != nil { + return nil, fmt.Errorf("failed to generate Sequin app secret: %w", err) + } + secrets = append(secrets, appSecret) + + // Sequin database role password + sequinPassword, _, err := generateRoleSecret(project, "sequin", "sequin") + if err != nil { + return nil, fmt.Errorf("failed to generate sequin password: %w", err) + } + secrets = append(secrets, sequinPassword) + + // Sequin replication role password + replicationPassword, _, err := generateRoleSecret(project, "sequin-replication", "sequin_replication") + if err != nil { + return nil, fmt.Errorf("failed to generate sequin-replication password: %w", err) + } + secrets = append(secrets, replicationPassword) + + return secrets, nil +} + +// SequinSecretNames returns the expected secret names for Sequin +func SequinSecretNames(project *supabasev1alpha1.SupabaseProject) (sequin, sequinPassword, sequinReplicationPassword string) { + return project.Name + "-sequin", + project.Name + "-sequin-password", + project.Name + "-sequin-replication-password" +} + +// generateSequinAppSecret creates the Sequin application secret +func generateSequinAppSecret(project *supabasev1alpha1.SupabaseProject) (*corev1.Secret, error) { + secretName := project.Name + "-sequin" + + // SECRET_KEY_BASE: 64 bytes hex (128 chars) + secretKeyBase, err := crypto.GenerateHex(64) + if err != nil { + return nil, fmt.Errorf("generating secretKeyBase: %w", err) + } + + // VAULT_KEY: 32 bytes base64 + vaultKey, err := crypto.GenerateBase64(32) + if err != nil { + return nil, fmt.Errorf("generating vaultKey: %w", err) + } + + // API token: 32 bytes hex + apiToken, err := crypto.GenerateHex(32) + if err != nil { + return nil, fmt.Errorf("generating apiToken: %w", err) + } + + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: project.Namespace, + Labels: common.ComponentLabels(project, "sequin"), + }, + Type: corev1.SecretTypeOpaque, + StringData: map[string]string{ + "secretKeyBase": secretKeyBase, + "vaultKey": vaultKey, + "apiToken": apiToken, + }, + }, nil +} diff --git a/internal/resources/services/services.go b/internal/resources/services/services.go index f6ecddb..a705635 100644 --- a/internal/resources/services/services.go +++ b/internal/resources/services/services.go @@ -68,6 +68,35 @@ func BuildMetaService(project *supabasev1alpha1.SupabaseProject) *corev1.Service return BuildService(project, project.Name+"-meta", "meta", 8080) } +// BuildSequinService creates the service for Sequin with HTTP and metrics ports +func BuildSequinService(project *supabasev1alpha1.SupabaseProject) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: project.Name + "-sequin", + Namespace: project.Namespace, + Labels: common.ComponentLabels(project, "sequin"), + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Selector: common.SelectorLabels(project, "sequin"), + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 7376, + TargetPort: intstr.FromInt(7376), + Protocol: corev1.ProtocolTCP, + }, + { + Name: "metrics", + Port: 4000, + TargetPort: intstr.FromInt(4000), + Protocol: corev1.ProtocolTCP, + }, + }, + }, + } +} + // BuildKongService creates the service for Kong func BuildKongService(project *supabasev1alpha1.SupabaseProject) *corev1.Service { return &corev1.Service{ From 7bf8e35af652a023c8587c94d55499b0e9d516bb Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 7 Feb 2026 20:01:04 +0800 Subject: [PATCH 04/21] feat(powersync): add Phase 2 Powersync integration - deployments, config, and controller - Add Powersync config.json ConfigMap builder with env var placeholders - Add sync rules ConfigMap builder (inline, configMapRef, or default) - Add Powersync API deployment (entry-api.js, client-facing, multi-replica) - Add Powersync Replication deployment (entry-replication.js, single instance) - Add Powersync Compact CronJob (entry-compact.js, configurable schedule) - Add Powersync API service (ports 8080 http + 9464 metrics) - Add Powersync secret generation and CNPG powersync_storage role - Extend CDC permissions Job for conditional Powersync grants - Add reconcilePowersync controller with ConfigMap, deployment, service, CronJob - Add CronJob RBAC permissions Co-Authored-By: Claude Opus 4.6 --- api/v1alpha1/supabaseproject_types.go | 4 + .../supabase.guion.dev_supabaseprojects.yaml | 4 + config/rbac/role.yaml | 1 + .../controller/supabaseproject_controller.go | 167 +++++++- internal/resources/cnpg/cluster.go | 20 +- internal/resources/configmaps/powersync.go | 170 ++++++++ internal/resources/deployments/powersync.go | 363 ++++++++++++++++++ internal/resources/jobs/cdc_permissions.go | 62 ++- internal/resources/secrets/secrets.go | 19 + internal/resources/services/services.go | 29 ++ 10 files changed, 819 insertions(+), 20 deletions(-) create mode 100644 internal/resources/configmaps/powersync.go create mode 100644 internal/resources/deployments/powersync.go diff --git a/api/v1alpha1/supabaseproject_types.go b/api/v1alpha1/supabaseproject_types.go index 6074667..3edff92 100644 --- a/api/v1alpha1/supabaseproject_types.go +++ b/api/v1alpha1/supabaseproject_types.go @@ -851,6 +851,10 @@ type SecretNamesStatus struct { // SequinReplicationPassword is the name of the sequin_replication role password secret // +optional SequinReplicationPassword string `json:"sequinReplicationPassword,omitempty"` + + // PowersyncStoragePassword is the name of the powersync_storage role password secret + // +optional + PowersyncStoragePassword string `json:"powersyncStoragePassword,omitempty"` } // EndpointsStatus contains service endpoints diff --git a/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml b/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml index c9e7a87..1d4b44f 100644 --- a/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml +++ b/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml @@ -1740,6 +1740,10 @@ spec: jwt: description: JWT is the name of the JWT secret type: string + powersyncStoragePassword: + description: PowersyncStoragePassword is the name of the powersync_storage + role password secret + type: string sequin: description: Sequin is the name of the Sequin secret (secretKeyBase, vaultKey, apiToken) diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 7dbdf82..eac4600 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -45,6 +45,7 @@ rules: - apiGroups: - batch resources: + - cronjobs - jobs verbs: - create diff --git a/internal/controller/supabaseproject_controller.go b/internal/controller/supabaseproject_controller.go index 94f446c..01607b6 100644 --- a/internal/controller/supabaseproject_controller.go +++ b/internal/controller/supabaseproject_controller.go @@ -95,6 +95,7 @@ type SupabaseProjectReconciler struct { // +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=batch,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. @@ -150,14 +151,21 @@ func (r *SupabaseProjectReconciler) Reconcile(ctx context.Context, req ctrl.Requ } // Phase 6: CDC Services (after core services) - if project.Spec.Sequin != nil { + if project.Spec.Sequin != nil || project.Spec.Powersync != nil { if err := r.reconcileCDCPermissions(ctx, project); err != nil { return ctrl.Result{}, err } + } + if project.Spec.Sequin != nil { if err := r.reconcileSequin(ctx, project); err != nil { return ctrl.Result{}, err } } + if project.Spec.Powersync != nil { + if err := r.reconcilePowersync(ctx, project); err != nil { + return ctrl.Result{}, err + } + } // All phases complete project.Status.Phase = supabasev1alpha1.PhaseRunning @@ -295,12 +303,17 @@ func (r *SupabaseProjectReconciler) reconcileAutoGeneratedSecrets(ctx context.Co if allExist { log.Info("Secrets already exist in cluster, syncing status") - // Also sync Sequin secrets if Sequin is enabled + // Also sync optional service secrets if project.Spec.Sequin != nil { if err := r.reconcileSequinSecrets(ctx, project, &secretNames); err != nil { return err } } + if project.Spec.Powersync != nil { + if err := r.reconcilePowersyncSecrets(ctx, project, &secretNames); err != nil { + return err + } + } project.Status.SecretNames = secretNames r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionTrue, "SecretsExist", "All secrets exist") @@ -342,6 +355,13 @@ func (r *SupabaseProjectReconciler) reconcileAutoGeneratedSecrets(ctx context.Co } } + // Generate Powersync secrets if Powersync is enabled + if project.Spec.Powersync != nil { + if err := r.reconcilePowersyncSecrets(ctx, project, &secretNames); err != nil { + return err + } + } + // Update status with secret names project.Status.SecretNames = secretNames @@ -407,6 +427,47 @@ func (r *SupabaseProjectReconciler) reconcileSequinSecrets(ctx context.Context, return nil } +// reconcilePowersyncSecrets generates Powersync-related secrets if they don't exist +func (r *SupabaseProjectReconciler) reconcilePowersyncSecrets(ctx context.Context, project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) error { + log := logf.FromContext(ctx) + + storagePwdName := secrets.PowersyncSecretNames(project) + + // Check if secret exists + existing := &corev1.Secret{} + if err := r.Get(ctx, types.NamespacedName{Name: storagePwdName, Namespace: project.Namespace}, existing); err == nil { + log.Info("Powersync secrets already exist, syncing status") + secretNames.PowersyncStoragePassword = storagePwdName + return nil + } else if !apierrors.IsNotFound(err) { + return err + } + + // Generate Powersync secrets + log.Info("Generating Powersync secrets") + psSecrets, err := secrets.GeneratePowersyncSecrets(project) + if err != nil { + r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionFalse, "PowersyncSecretsFailed", err.Error()) + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return statusErr + } + return err + } + + for _, secret := range psSecrets { + if err := r.createOrUpdateSecret(ctx, project, secret); err != nil { + r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionFalse, "CreateFailed", err.Error()) + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return statusErr + } + return err + } + } + + secretNames.PowersyncStoragePassword = storagePwdName + return nil +} + // reconcileInitSQL ensures the init SQL ConfigMap exists func (r *SupabaseProjectReconciler) reconcileInitSQL(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { log := logf.FromContext(ctx) @@ -1083,6 +1144,107 @@ func (r *SupabaseProjectReconciler) reconcileSequin(ctx context.Context, project return nil } +// reconcilePowersync deploys the Powersync service (API + Replication + ConfigMaps + CronJob) +func (r *SupabaseProjectReconciler) reconcilePowersync(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { + log := logf.FromContext(ctx) + log.Info("Reconciling Powersync service") + + secretNames := &project.Status.SecretNames + dbHost := cnpg.ClusterRWServiceName(project) + + // Create Powersync config ConfigMap + psConfig := configmaps.BuildPowersyncConfigMap(project, dbHost) + if err := r.createOrUpdateConfigMap(ctx, project, psConfig); err != nil { + r.setCondition(project, supabasev1alpha1.ConditionTypePowersyncReady, metav1.ConditionFalse, "ConfigMapFailed", err.Error()) + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return statusErr + } + return err + } + + // Create sync rules ConfigMap (may be nil if external ConfigMapRef is used) + syncRules := configmaps.BuildPowersyncSyncRulesConfigMap(project) + if syncRules != nil { + if err := r.createOrUpdateConfigMap(ctx, project, syncRules); err != nil { + r.setCondition(project, supabasev1alpha1.ConditionTypePowersyncReady, metav1.ConditionFalse, "SyncRulesConfigMapFailed", err.Error()) + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return statusErr + } + return err + } + } + + // Deploy Powersync API + apiDeployment := deployments.BuildPowersyncAPIDeployment(project, secretNames) + if err := r.createOrUpdateDeployment(ctx, project, apiDeployment); err != nil { + r.setCondition(project, supabasev1alpha1.ConditionTypePowersyncReady, metav1.ConditionFalse, "APIDeploymentFailed", err.Error()) + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return statusErr + } + return err + } + + // Create Powersync API service + apiService := services.BuildPowersyncAPIService(project) + if err := r.createOrUpdateService(ctx, project, apiService); err != nil { + r.setCondition(project, supabasev1alpha1.ConditionTypePowersyncReady, metav1.ConditionFalse, "APIServiceFailed", err.Error()) + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return statusErr + } + return err + } + + // Deploy Powersync Replication + replDeployment := deployments.BuildPowersyncReplicationDeployment(project, secretNames) + if err := r.createOrUpdateDeployment(ctx, project, replDeployment); err != nil { + r.setCondition(project, supabasev1alpha1.ConditionTypePowersyncReady, metav1.ConditionFalse, "ReplicationDeploymentFailed", err.Error()) + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return statusErr + } + return err + } + + // Deploy Powersync Compact CronJob + compactCronJob := deployments.BuildPowersyncCompactCronJob(project, secretNames) + if compactCronJob != nil { + if err := r.createOrUpdateCronJob(ctx, project, compactCronJob); err != nil { + r.setCondition(project, supabasev1alpha1.ConditionTypePowersyncReady, metav1.ConditionFalse, "CronJobFailed", err.Error()) + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return statusErr + } + return err + } + } + + project.Status.Services.PowersyncAPI = supabasev1alpha1.ServiceStatus{Ready: true} + project.Status.Services.PowersyncReplication = supabasev1alpha1.ServiceStatus{Ready: true} + r.setCondition(project, supabasev1alpha1.ConditionTypePowersyncReady, metav1.ConditionTrue, "Ready", "Powersync service is running") + return nil +} + +// createOrUpdateCronJob creates or updates a CronJob resource +func (r *SupabaseProjectReconciler) createOrUpdateCronJob(ctx context.Context, project *supabasev1alpha1.SupabaseProject, cronJob *batchv1.CronJob) error { + log := logf.FromContext(ctx) + + if err := controllerutil.SetControllerReference(project, cronJob, r.Scheme); err != nil { + return err + } + + existing := &batchv1.CronJob{} + err := r.Get(ctx, types.NamespacedName{Name: cronJob.Name, Namespace: cronJob.Namespace}, existing) + if err != nil { + if apierrors.IsNotFound(err) { + log.Info("Creating CronJob", "name", cronJob.Name) + return r.Create(ctx, cronJob) + } + return err + } + + // Update existing + existing.Spec = cronJob.Spec + return r.Update(ctx, existing) +} + // createOrCheckJob creates a Job if it doesn't exist, or checks status of existing Job func (r *SupabaseProjectReconciler) createOrCheckJob(ctx context.Context, project *supabasev1alpha1.SupabaseProject, job *batchv1.Job) error { log := logf.FromContext(ctx) @@ -1137,6 +1299,7 @@ func (r *SupabaseProjectReconciler) SetupWithManager(mgr ctrl.Manager) error { Owns(&corev1.Service{}). Owns(&appsv1.Deployment{}). Owns(&batchv1.Job{}). + Owns(&batchv1.CronJob{}). Owns(&cnpgv1.Cluster{}). Owns(&cnpgv1.ScheduledBackup{}). Owns(&barmancloudv1.ObjectStore{}). diff --git a/internal/resources/cnpg/cluster.go b/internal/resources/cnpg/cluster.go index 41093f1..1c4f156 100644 --- a/internal/resources/cnpg/cluster.go +++ b/internal/resources/cnpg/cluster.go @@ -196,12 +196,15 @@ func buildBootstrapConfiguration(project *supabasev1alpha1.SupabaseProject, secr } } -// buildAllRoles combines base Supabase roles with optional Sequin roles +// buildAllRoles combines base Supabase roles with optional CDC/search roles func buildAllRoles(project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) []cnpgv1.RoleConfiguration { roles := buildRoles(&project.Spec.Database, secretNames) if project.Spec.Sequin != nil && secretNames.SequinPassword != "" { roles = append(roles, BuildSequinRoles(secretNames)...) } + if project.Spec.Powersync != nil && secretNames.PowersyncStoragePassword != "" { + roles = append(roles, BuildPowersyncRoles(secretNames)...) + } return roles } @@ -231,6 +234,21 @@ func BuildSequinRoles(secretNames *supabasev1alpha1.SecretNamesStatus) []cnpgv1. } } +// BuildPowersyncRoles returns additional CNPG roles required for Powersync +func BuildPowersyncRoles(secretNames *supabasev1alpha1.SecretNamesStatus) []cnpgv1.RoleConfiguration { + return []cnpgv1.RoleConfiguration{ + { + Name: "powersync_storage", + Ensure: cnpgv1.EnsurePresent, + Login: true, + PasswordSecret: &cnpgv1.LocalObjectReference{ + Name: secretNames.PowersyncStoragePassword, + }, + Comment: "Powersync storage role", + }, + } +} + // buildRoles creates the managed roles for Supabase func buildRoles(spec *supabasev1alpha1.DatabaseSpec, secretNames *supabasev1alpha1.SecretNamesStatus) []cnpgv1.RoleConfiguration { roles := []cnpgv1.RoleConfiguration{ diff --git a/internal/resources/configmaps/powersync.go b/internal/resources/configmaps/powersync.go new file mode 100644 index 0000000..6da0e42 --- /dev/null +++ b/internal/resources/configmaps/powersync.go @@ -0,0 +1,170 @@ +/* +Copyright 2026 GuionAI. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package configmaps + +import ( + "encoding/json" + "fmt" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" + "github.com/GuionAI/cloudnative-supabase/internal/resources/common" +) + +const ( + PowersyncConfigComponentName = "powersync-config" + PowersyncSyncRulesComponentName = "powersync-sync-rules" +) + +// PowersyncConfigMapName returns the Powersync config ConfigMap name +func PowersyncConfigMapName(project *supabasev1alpha1.SupabaseProject) string { + return project.Name + "-powersync-config" +} + +// PowersyncSyncRulesConfigMapName returns the sync rules ConfigMap name +func PowersyncSyncRulesConfigMapName(project *supabasev1alpha1.SupabaseProject) string { + return project.Name + "-powersync-sync-rules" +} + +// powersyncConfig represents the PowerSync service config.json structure +type powersyncConfig struct { + Storage powersyncStorage `json:"storage"` + Replication powersyncReplication `json:"replication"` + ClientAuth powersyncClientAuth `json:"client_auth"` + SyncRules powersyncSyncRules `json:"sync_rules"` +} + +type powersyncStorage struct { + Type string `json:"type"` + URI string `json:"uri"` +} + +type powersyncReplication struct { + Connections []powersyncConnection `json:"connections"` +} + +type powersyncConnection struct { + Type string `json:"type"` + URI string `json:"uri"` + Tag string `json:"tag"` +} + +type powersyncClientAuth struct { + Supabase bool `json:"supabase"` + SupabaseJWTSecret string `json:"supabase_jwt_secret"` + Audience []string `json:"audience"` +} + +type powersyncSyncRules struct { + Path string `json:"path"` +} + +// BuildPowersyncConfigMap creates the PowerSync config.json ConfigMap. +// Database credentials are injected via environment variables that PowerSync resolves at runtime. +// The config uses connection strings with env var placeholders. +// dbHost is the database hostname (e.g., from cnpg.ClusterRWServiceName) - passed as parameter to avoid import cycle. +func BuildPowersyncConfigMap(project *supabasev1alpha1.SupabaseProject, dbHost string) *corev1.ConfigMap { + + // PowerSync config uses connection URIs with credentials from env vars + // Environment variables PS_STORAGE_URI, PS_REPLICATION_URI, PS_JWT_SECRET + // are set on the deployment from K8s secrets + config := powersyncConfig{ + Storage: powersyncStorage{ + Type: "postgresql", + // Will be overridden by PS_POWERSYNC_STORAGE_URI env var + URI: fmt.Sprintf("postgresql://powersync_storage@%s:5432/supabase?sslmode=disable", dbHost), + }, + Replication: powersyncReplication{ + Connections: []powersyncConnection{ + { + Type: "postgresql", + // Will be overridden by PS_POWERSYNC_REPLICATION_URI env var + URI: fmt.Sprintf("postgresql://powersync_storage@%s:5432/supabase?sslmode=disable", dbHost), + Tag: "default", + }, + }, + }, + ClientAuth: powersyncClientAuth{ + Supabase: true, + SupabaseJWTSecret: "{{ env.PS_JWT_SECRET }}", + Audience: []string{"authenticated"}, + }, + SyncRules: powersyncSyncRules{ + Path: "/powersync/sync_rules/sync_rules.yaml", + }, + } + + configJSON, _ := json.MarshalIndent(config, "", " ") + + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: PowersyncConfigMapName(project), + Namespace: project.Namespace, + Labels: common.ComponentLabels(project, PowersyncConfigComponentName), + }, + Data: map[string]string{ + "config.json": string(configJSON), + }, + } +} + +// BuildPowersyncSyncRulesConfigMap creates the sync rules ConfigMap. +// Returns nil if an external ConfigMapRef is specified (the deployment references it directly). +func BuildPowersyncSyncRulesConfigMap(project *supabasev1alpha1.SupabaseProject) *corev1.ConfigMap { + spec := project.Spec.Powersync + + // If using external ConfigMap reference, don't create our own + if spec.SyncRules.ConfigMapRef != "" { + return nil + } + + // Use inline sync rules or default + syncRules := spec.SyncRules.Inline + if syncRules == "" { + syncRules = defaultSyncRules() + } + + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: PowersyncSyncRulesConfigMapName(project), + Namespace: project.Namespace, + Labels: common.ComponentLabels(project, PowersyncSyncRulesComponentName), + }, + Data: map[string]string{ + "sync_rules.yaml": syncRules, + }, + } +} + +// SyncRulesConfigMapName returns the actual ConfigMap name for sync rules +// (either operator-generated or user-provided external) +func SyncRulesConfigMapName(project *supabasev1alpha1.SupabaseProject) string { + if project.Spec.Powersync.SyncRules.ConfigMapRef != "" { + return project.Spec.Powersync.SyncRules.ConfigMapRef + } + return PowersyncSyncRulesConfigMapName(project) +} + +func defaultSyncRules() string { + return `bucket_definitions: + global: + data: + - SELECT * FROM public.* +` +} diff --git a/internal/resources/deployments/powersync.go b/internal/resources/deployments/powersync.go new file mode 100644 index 0000000..c0ba335 --- /dev/null +++ b/internal/resources/deployments/powersync.go @@ -0,0 +1,363 @@ +/* +Copyright 2026 GuionAI. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package deployments + +import ( + "fmt" + + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" + "github.com/GuionAI/cloudnative-supabase/internal/resources/cnpg" + "github.com/GuionAI/cloudnative-supabase/internal/resources/common" + "github.com/GuionAI/cloudnative-supabase/internal/resources/configmaps" + "github.com/GuionAI/cloudnative-supabase/internal/resources/defaults" +) + +const ( + PowersyncAPIComponentName = "powersync-api" + PowersyncReplicationComponentName = "powersync-replication" + PowersyncCompactComponentName = "powersync-compact" + PowersyncHTTPPort int32 = 8080 + PowersyncMetricsPort int32 = 9464 +) + +// PowersyncAPIDeploymentName returns the Powersync API deployment name +func PowersyncAPIDeploymentName(project *supabasev1alpha1.SupabaseProject) string { + return project.Name + "-powersync-api" +} + +// PowersyncReplicationDeploymentName returns the Powersync replication deployment name +func PowersyncReplicationDeploymentName(project *supabasev1alpha1.SupabaseProject) string { + return project.Name + "-powersync-replication" +} + +// PowersyncCompactCronJobName returns the Powersync compact CronJob name +func PowersyncCompactCronJobName(project *supabasev1alpha1.SupabaseProject) string { + return project.Name + "-powersync-compact" +} + +// DefaultPowersyncAPIResources returns default resource requirements for Powersync API +func DefaultPowersyncAPIResources() corev1.ResourceRequirements { + return corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("256Mi"), + corev1.ResourceCPU: resource.MustParse("100m"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("512Mi"), + corev1.ResourceCPU: resource.MustParse("500m"), + }, + } +} + +// DefaultPowersyncReplicationResources returns default resource requirements for Powersync replication +func DefaultPowersyncReplicationResources() corev1.ResourceRequirements { + return corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("256Mi"), + corev1.ResourceCPU: resource.MustParse("100m"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("768Mi"), + corev1.ResourceCPU: resource.MustParse("500m"), + }, + } +} + +// BuildPowersyncAPIDeployment creates the Powersync API deployment (client-facing) +func BuildPowersyncAPIDeployment(project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) *appsv1.Deployment { + spec := project.Spec.Powersync + name := PowersyncAPIDeploymentName(project) + image := ResolveImage(spec.Image, defaults.PowersyncImage, defaults.PowersyncTag) + pullPolicy := ResolvePullPolicy(spec.Image) + replicas := NormalizeReplicas(spec.API.Replicas) + resources := normalizePowersyncResources(spec.API.Resources, DefaultPowersyncAPIResources()) + + nodeOptions := spec.API.NodeOptions + if nodeOptions == "" { + nodeOptions = "--max-old-space-size=330" + } + + env := buildPowersyncEnv(project, secretNames, nodeOptions) + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: project.Namespace, + Labels: common.ComponentLabels(project, PowersyncAPIComponentName), + Annotations: common.ReloaderAnnotations(), + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: common.SelectorLabels(project, PowersyncAPIComponentName), + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: common.ComponentLabels(project, PowersyncAPIComponentName), + Annotations: common.ReloaderAnnotations(), + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "powersync-api", + Image: image, + ImagePullPolicy: pullPolicy, + Command: []string{"node", "entry-api.js"}, + Env: env, + Ports: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: PowersyncHTTPPort, + Protocol: corev1.ProtocolTCP, + }, + { + Name: "metrics", + ContainerPort: PowersyncMetricsPort, + Protocol: corev1.ProtocolTCP, + }, + }, + LivenessProbe: BuildLivenessProbe("/api/status", PowersyncHTTPPort), + ReadinessProbe: BuildReadinessProbe("/api/status", PowersyncHTTPPort), + Resources: resources, + VolumeMounts: powersyncVolumeMounts(), + }, + }, + Volumes: powersyncVolumes(project), + }, + }, + }, + } + + AddImagePullSecrets(&deployment.Spec.Template.Spec, project) + return deployment +} + +// BuildPowersyncReplicationDeployment creates the Powersync replication deployment (CDC processing) +func BuildPowersyncReplicationDeployment(project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) *appsv1.Deployment { + spec := project.Spec.Powersync + name := PowersyncReplicationDeploymentName(project) + image := ResolveImage(spec.Image, defaults.PowersyncImage, defaults.PowersyncTag) + pullPolicy := ResolvePullPolicy(spec.Image) + var replicas int32 = 1 // Replication is always single instance + resources := normalizePowersyncResources(spec.Replication.Resources, DefaultPowersyncReplicationResources()) + + nodeOptions := spec.Replication.NodeOptions + if nodeOptions == "" { + nodeOptions = "--max-old-space-size=482" + } + + env := buildPowersyncEnv(project, secretNames, nodeOptions) + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: project.Namespace, + Labels: common.ComponentLabels(project, PowersyncReplicationComponentName), + Annotations: common.ReloaderAnnotations(), + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: common.SelectorLabels(project, PowersyncReplicationComponentName), + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: common.ComponentLabels(project, PowersyncReplicationComponentName), + Annotations: common.ReloaderAnnotations(), + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "powersync-replication", + Image: image, + ImagePullPolicy: pullPolicy, + Command: []string{"node", "entry-replication.js"}, + Env: env, + Ports: []corev1.ContainerPort{ + { + Name: "metrics", + ContainerPort: PowersyncMetricsPort, + Protocol: corev1.ProtocolTCP, + }, + }, + Resources: resources, + VolumeMounts: powersyncVolumeMounts(), + }, + }, + Volumes: powersyncVolumes(project), + }, + }, + }, + } + + AddImagePullSecrets(&deployment.Spec.Template.Spec, project) + return deployment +} + +// BuildPowersyncCompactCronJob creates the Powersync compact CronJob +func BuildPowersyncCompactCronJob(project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) *batchv1.CronJob { + spec := project.Spec.Powersync + + if !spec.Compact.Enabled { + return nil + } + + name := PowersyncCompactCronJobName(project) + image := ResolveImage(spec.Image, defaults.PowersyncImage, defaults.PowersyncTag) + pullPolicy := ResolvePullPolicy(spec.Image) + resources := normalizePowersyncResources(spec.Compact.Resources, DefaultPowersyncAPIResources()) + + schedule := spec.Compact.Schedule + if schedule == "" { + schedule = "0 3 * * *" + } + + env := buildPowersyncEnv(project, secretNames, "--max-old-space-size=330") + + return &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: project.Namespace, + Labels: common.ComponentLabels(project, PowersyncCompactComponentName), + }, + Spec: batchv1.CronJobSpec{ + Schedule: schedule, + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: common.ComponentLabels(project, PowersyncCompactComponentName), + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyOnFailure, + Containers: []corev1.Container{ + { + Name: "powersync-compact", + Image: image, + ImagePullPolicy: pullPolicy, + Command: []string{"node", "entry-compact.js"}, + Env: env, + Resources: resources, + VolumeMounts: powersyncVolumeMounts(), + }, + }, + Volumes: powersyncVolumes(project), + }, + }, + }, + }, + }, + } +} + +// buildPowersyncEnv builds environment variables shared by all Powersync containers +func buildPowersyncEnv(project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus, nodeOptions string) []corev1.EnvVar { + dbHost := cnpg.ClusterRWServiceName(project) + + return []corev1.EnvVar{ + {Name: "POWERSYNC_CONFIG_PATH", Value: "/powersync/config/config.json"}, + {Name: "NODE_OPTIONS", Value: nodeOptions}, + // Database password from secret for connection string construction + { + Name: "PS_PG_PASSWORD", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secretNames.PowersyncStoragePassword, + }, + Key: "password", + }, + }, + }, + // PowerSync resolves {{ env.VAR }} in config.json + { + Name: "PS_POWERSYNC_STORAGE_URI", + Value: fmt.Sprintf("postgresql://powersync_storage:$(PS_PG_PASSWORD)@%s:5432/supabase?sslmode=disable", dbHost), + }, + { + Name: "PS_POWERSYNC_REPLICATION_URI", + Value: fmt.Sprintf("postgresql://powersync_storage:$(PS_PG_PASSWORD)@%s:5432/supabase?sslmode=disable", dbHost), + }, + // JWT secret for client authentication + { + Name: "PS_JWT_SECRET", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secretNames.JWT, + }, + Key: "secret", + }, + }, + }, + } +} + +// powersyncVolumeMounts returns the shared volume mounts for Powersync containers +func powersyncVolumeMounts() []corev1.VolumeMount { + return []corev1.VolumeMount{ + { + Name: "config", + MountPath: "/powersync/config", + ReadOnly: true, + }, + { + Name: "sync-rules", + MountPath: "/powersync/sync_rules", + ReadOnly: true, + }, + } +} + +// powersyncVolumes returns the shared volumes for Powersync pods +func powersyncVolumes(project *supabasev1alpha1.SupabaseProject) []corev1.Volume { + return []corev1.Volume{ + { + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: configmaps.PowersyncConfigMapName(project), + }, + }, + }, + }, + { + Name: "sync-rules", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: configmaps.SyncRulesConfigMapName(project), + }, + }, + }, + }, + } +} + +func normalizePowersyncResources(resources corev1.ResourceRequirements, defaults corev1.ResourceRequirements) corev1.ResourceRequirements { + if len(resources.Requests) == 0 && len(resources.Limits) == 0 { + return defaults + } + return resources +} diff --git a/internal/resources/jobs/cdc_permissions.go b/internal/resources/jobs/cdc_permissions.go index fa07155..943567d 100644 --- a/internal/resources/jobs/cdc_permissions.go +++ b/internal/resources/jobs/cdc_permissions.go @@ -45,14 +45,32 @@ func CDCJobName(project *supabasev1alpha1.SupabaseProject) string { // BuildCDCMigrationsConfigMap creates the ConfigMap containing CDC setup scripts func BuildCDCMigrationsConfigMap(project *supabasev1alpha1.SupabaseProject) *corev1.ConfigMap { - // Shell script that handles both database creation and grants - // Uses psql which handles CREATE DATABASE outside transactions - setupScript := `#!/bin/sh + setupScript := buildCDCSetupScript(project) + + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: CDCConfigMapName(project), + Namespace: project.Namespace, + Labels: common.ComponentLabels(project, CDCComponentName), + }, + Data: map[string]string{ + "setup.sh": setupScript, + }, + } +} + +// buildCDCSetupScript generates the CDC setup shell script based on enabled services +func buildCDCSetupScript(project *supabasev1alpha1.SupabaseProject) string { + script := `#!/bin/sh set -e echo "=== CDC Permissions Setup ===" +` -# Step 1: Create sequin database if it doesn't exist + // Sequin-specific grants + if project.Spec.Sequin != nil { + script += ` +# Create sequin database if it doesn't exist echo "Checking if sequin database exists..." DB_EXISTS=$(psql "$PGCONNSTR" -tAc "SELECT 1 FROM pg_database WHERE datname='sequin'" 2>/dev/null || echo "0") if [ "$DB_EXISTS" != "1" ]; then @@ -63,8 +81,8 @@ else echo "Sequin database already exists" fi -# Step 2: Apply CDC grants (idempotent) -echo "Applying CDC grants..." +# Apply Sequin CDC grants +echo "Applying Sequin CDC grants..." psql "$PGCONNSTR" <<'EOSQL' -- Grant CDC role (sequin_replication) read access to public schema GRANT USAGE ON SCHEMA public TO sequin_replication; @@ -78,20 +96,30 @@ ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT SELECT O -- Grant SELECT on existing tables in public schema GRANT SELECT ON ALL TABLES IN SCHEMA public TO sequin_replication; EOSQL - -echo "=== CDC Permissions Setup Complete ===" ` + } - return &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: CDCConfigMapName(project), - Namespace: project.Namespace, - Labels: common.ComponentLabels(project, CDCComponentName), - }, - Data: map[string]string{ - "setup.sh": setupScript, - }, + // Powersync-specific grants + if project.Spec.Powersync != nil { + script += ` +# Apply Powersync grants +echo "Applying Powersync grants..." +psql "$PGCONNSTR" <<'EOSQL' +-- Grant powersync_storage role access to create its schema +GRANT CREATE ON DATABASE supabase TO powersync_storage; + +-- Grant usage on public schema for replication reads +GRANT USAGE ON SCHEMA public TO powersync_storage; +GRANT SELECT ON ALL TABLES IN SCHEMA public TO powersync_storage; +ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT SELECT ON TABLES TO powersync_storage; +EOSQL +` } + + script += ` +echo "=== CDC Permissions Setup Complete ===" +` + return script } // BuildCDCPermissionsJob creates the Job that applies CDC permissions after database is ready diff --git a/internal/resources/secrets/secrets.go b/internal/resources/secrets/secrets.go index 85ac98b..e3315c0 100644 --- a/internal/resources/secrets/secrets.go +++ b/internal/resources/secrets/secrets.go @@ -208,6 +208,25 @@ func SequinSecretNames(project *supabasev1alpha1.SupabaseProject) (sequin, sequi project.Name + "-sequin-replication-password" } +// GeneratePowersyncSecrets generates Powersync-related secrets +func GeneratePowersyncSecrets(project *supabasev1alpha1.SupabaseProject) ([]*corev1.Secret, error) { + var secrets []*corev1.Secret + + // Powersync storage role password + storagePassword, _, err := generateRoleSecret(project, "powersync-storage", "powersync_storage") + if err != nil { + return nil, fmt.Errorf("failed to generate powersync-storage password: %w", err) + } + secrets = append(secrets, storagePassword) + + return secrets, nil +} + +// PowersyncSecretNames returns the expected secret names for Powersync +func PowersyncSecretNames(project *supabasev1alpha1.SupabaseProject) (powersyncStoragePassword string) { + return project.Name + "-powersync-storage-password" +} + // generateSequinAppSecret creates the Sequin application secret func generateSequinAppSecret(project *supabasev1alpha1.SupabaseProject) (*corev1.Secret, error) { secretName := project.Name + "-sequin" diff --git a/internal/resources/services/services.go b/internal/resources/services/services.go index a705635..815d2ba 100644 --- a/internal/resources/services/services.go +++ b/internal/resources/services/services.go @@ -97,6 +97,35 @@ func BuildSequinService(project *supabasev1alpha1.SupabaseProject) *corev1.Servi } } +// BuildPowersyncAPIService creates the service for Powersync API with HTTP and metrics ports +func BuildPowersyncAPIService(project *supabasev1alpha1.SupabaseProject) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: project.Name + "-powersync-api", + Namespace: project.Namespace, + Labels: common.ComponentLabels(project, "powersync-api"), + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Selector: common.SelectorLabels(project, "powersync-api"), + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 8080, + TargetPort: intstr.FromInt(8080), + Protocol: corev1.ProtocolTCP, + }, + { + Name: "metrics", + Port: 9464, + TargetPort: intstr.FromInt(9464), + Protocol: corev1.ProtocolTCP, + }, + }, + }, + } +} + // BuildKongService creates the service for Kong func BuildKongService(project *supabasev1alpha1.SupabaseProject) *corev1.Service { return &corev1.Service{ From e5528a7e4cd22b8073041e238fb973475ffb802c Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 7 Feb 2026 20:08:16 +0800 Subject: [PATCH 05/21] feat(meilisearch): add Phase 3 Meilisearch integration - StatefulSet, secrets, and controller - Add Meilisearch master key secret generation (auto-gen or user-provided) - Add MeilisearchMasterKey to SecretNamesStatus - Add Meilisearch StatefulSet builder with PVC, health probes, and env vars - Add Meilisearch service (port 7700) - Add reconcileMeilisearch controller with StatefulSet + Service - Add createOrUpdateStatefulSet helper (immutable VolumeClaimTemplates) - Add StatefulSet RBAC permissions - Wire into Phase 7 of reconcile loop Co-Authored-By: Claude Opus 4.6 --- api/v1alpha1/supabaseproject_types.go | 4 + .../supabase.guion.dev_supabaseprojects.yaml | 4 + config/rbac/role.yaml | 1 + .../controller/supabaseproject_controller.go | 124 +++++++++++++ internal/resources/deployments/meilisearch.go | 169 ++++++++++++++++++ internal/resources/secrets/secrets.go | 37 ++++ internal/resources/services/services.go | 5 + 7 files changed, 344 insertions(+) create mode 100644 internal/resources/deployments/meilisearch.go diff --git a/api/v1alpha1/supabaseproject_types.go b/api/v1alpha1/supabaseproject_types.go index 3edff92..55b8f32 100644 --- a/api/v1alpha1/supabaseproject_types.go +++ b/api/v1alpha1/supabaseproject_types.go @@ -855,6 +855,10 @@ type SecretNamesStatus struct { // PowersyncStoragePassword is the name of the powersync_storage role password secret // +optional PowersyncStoragePassword string `json:"powersyncStoragePassword,omitempty"` + + // MeilisearchMasterKey is the name of the Meilisearch master key secret + // +optional + MeilisearchMasterKey string `json:"meilisearchMasterKey,omitempty"` } // EndpointsStatus contains service endpoints diff --git a/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml b/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml index 1d4b44f..15d2221 100644 --- a/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml +++ b/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml @@ -1740,6 +1740,10 @@ spec: jwt: description: JWT is the name of the JWT secret type: string + meilisearchMasterKey: + description: MeilisearchMasterKey is the name of the Meilisearch + master key secret + type: string powersyncStoragePassword: description: PowersyncStoragePassword is the name of the powersync_storage role password secret diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index eac4600..285b500 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -22,6 +22,7 @@ rules: - apps resources: - deployments + - statefulsets verbs: - create - delete diff --git a/internal/controller/supabaseproject_controller.go b/internal/controller/supabaseproject_controller.go index 01607b6..cc9ab2b 100644 --- a/internal/controller/supabaseproject_controller.go +++ b/internal/controller/supabaseproject_controller.go @@ -96,6 +96,7 @@ type SupabaseProjectReconciler struct { // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=batch,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch;delete // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. @@ -167,6 +168,13 @@ func (r *SupabaseProjectReconciler) Reconcile(ctx context.Context, req ctrl.Requ } } + // Phase 7: Search Service + if project.Spec.Meilisearch != nil { + if err := r.reconcileMeilisearch(ctx, project); err != nil { + return ctrl.Result{}, err + } + } + // All phases complete project.Status.Phase = supabasev1alpha1.PhaseRunning project.Status.ObservedGeneration = project.Generation @@ -314,6 +322,11 @@ func (r *SupabaseProjectReconciler) reconcileAutoGeneratedSecrets(ctx context.Co return err } } + if project.Spec.Meilisearch != nil { + if err := r.reconcileMeilisearchSecrets(ctx, project, &secretNames); err != nil { + return err + } + } project.Status.SecretNames = secretNames r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionTrue, "SecretsExist", "All secrets exist") @@ -362,6 +375,13 @@ func (r *SupabaseProjectReconciler) reconcileAutoGeneratedSecrets(ctx context.Co } } + // Generate Meilisearch secrets if Meilisearch is enabled + if project.Spec.Meilisearch != nil { + if err := r.reconcileMeilisearchSecrets(ctx, project, &secretNames); err != nil { + return err + } + } + // Update status with secret names project.Status.SecretNames = secretNames @@ -468,6 +488,53 @@ func (r *SupabaseProjectReconciler) reconcilePowersyncSecrets(ctx context.Contex return nil } +// reconcileMeilisearchSecrets generates Meilisearch-related secrets if they don't exist +func (r *SupabaseProjectReconciler) reconcileMeilisearchSecrets(ctx context.Context, project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) error { + log := logf.FromContext(ctx) + + msSecretName := secrets.MeilisearchSecretName(project) + + // Check if secret exists + existing := &corev1.Secret{} + if err := r.Get(ctx, types.NamespacedName{Name: msSecretName, Namespace: project.Namespace}, existing); err == nil { + log.Info("Meilisearch secrets already exist, syncing status") + secretNames.MeilisearchMasterKey = msSecretName + return nil + } else if !apierrors.IsNotFound(err) { + return err + } + + // If user provided a secret ref, just use it (no generation needed) + if project.Spec.Meilisearch.MasterKeySecretRef != "" { + secretNames.MeilisearchMasterKey = project.Spec.Meilisearch.MasterKeySecretRef + return nil + } + + // Generate Meilisearch secrets + log.Info("Generating Meilisearch secrets") + msSecrets, err := secrets.GenerateMeilisearchSecrets(project) + if err != nil { + r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionFalse, "MeilisearchSecretsFailed", err.Error()) + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return statusErr + } + return err + } + + for _, secret := range msSecrets { + if err := r.createOrUpdateSecret(ctx, project, secret); err != nil { + r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionFalse, "CreateFailed", err.Error()) + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return statusErr + } + return err + } + } + + secretNames.MeilisearchMasterKey = msSecretName + return nil +} + // reconcileInitSQL ensures the init SQL ConfigMap exists func (r *SupabaseProjectReconciler) reconcileInitSQL(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { log := logf.FromContext(ctx) @@ -1222,6 +1289,62 @@ func (r *SupabaseProjectReconciler) reconcilePowersync(ctx context.Context, proj return nil } +// reconcileMeilisearch deploys the Meilisearch service (StatefulSet + Service) +func (r *SupabaseProjectReconciler) reconcileMeilisearch(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { + log := logf.FromContext(ctx) + log.Info("Reconciling Meilisearch service") + + secretNames := &project.Status.SecretNames + + // Create StatefulSet + sts := deployments.BuildMeilisearchStatefulSet(project, secretNames) + if err := r.createOrUpdateStatefulSet(ctx, project, sts); err != nil { + r.setCondition(project, supabasev1alpha1.ConditionTypeMeilisearchReady, metav1.ConditionFalse, "StatefulSetFailed", err.Error()) + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return statusErr + } + return err + } + + // Create service + service := services.BuildMeilisearchService(project) + if err := r.createOrUpdateService(ctx, project, service); err != nil { + r.setCondition(project, supabasev1alpha1.ConditionTypeMeilisearchReady, metav1.ConditionFalse, "ServiceFailed", err.Error()) + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return statusErr + } + return err + } + + project.Status.Services.Meilisearch = supabasev1alpha1.ServiceStatus{Ready: true} + r.setCondition(project, supabasev1alpha1.ConditionTypeMeilisearchReady, metav1.ConditionTrue, "Ready", "Meilisearch service is running") + return nil +} + +// createOrUpdateStatefulSet creates or updates a StatefulSet resource +func (r *SupabaseProjectReconciler) createOrUpdateStatefulSet(ctx context.Context, project *supabasev1alpha1.SupabaseProject, sts *appsv1.StatefulSet) error { + log := logf.FromContext(ctx) + + if err := controllerutil.SetControllerReference(project, sts, r.Scheme); err != nil { + return err + } + + existing := &appsv1.StatefulSet{} + err := r.Get(ctx, types.NamespacedName{Name: sts.Name, Namespace: sts.Namespace}, existing) + if err != nil { + if apierrors.IsNotFound(err) { + log.Info("Creating StatefulSet", "name", sts.Name) + return r.Create(ctx, sts) + } + return err + } + + // Update existing - only update mutable fields (VolumeClaimTemplates are immutable) + existing.Spec.Replicas = sts.Spec.Replicas + existing.Spec.Template = sts.Spec.Template + return r.Update(ctx, existing) +} + // createOrUpdateCronJob creates or updates a CronJob resource func (r *SupabaseProjectReconciler) createOrUpdateCronJob(ctx context.Context, project *supabasev1alpha1.SupabaseProject, cronJob *batchv1.CronJob) error { log := logf.FromContext(ctx) @@ -1298,6 +1421,7 @@ func (r *SupabaseProjectReconciler) SetupWithManager(mgr ctrl.Manager) error { Owns(&corev1.ConfigMap{}). Owns(&corev1.Service{}). Owns(&appsv1.Deployment{}). + Owns(&appsv1.StatefulSet{}). Owns(&batchv1.Job{}). Owns(&batchv1.CronJob{}). Owns(&cnpgv1.Cluster{}). diff --git a/internal/resources/deployments/meilisearch.go b/internal/resources/deployments/meilisearch.go new file mode 100644 index 0000000..4ebc3a0 --- /dev/null +++ b/internal/resources/deployments/meilisearch.go @@ -0,0 +1,169 @@ +/* +Copyright 2026 GuionAI. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package deployments + +import ( + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" + "github.com/GuionAI/cloudnative-supabase/internal/resources/common" + "github.com/GuionAI/cloudnative-supabase/internal/resources/defaults" + "github.com/GuionAI/cloudnative-supabase/internal/resources/secrets" +) + +const ( + MeilisearchComponentName = "meilisearch" + MeilisearchHTTPPort int32 = 7700 +) + +// MeilisearchStatefulSetName returns the Meilisearch StatefulSet name +func MeilisearchStatefulSetName(project *supabasev1alpha1.SupabaseProject) string { + return project.Name + "-meilisearch" +} + +// DefaultMeilisearchResources returns default resource requirements for Meilisearch +func DefaultMeilisearchResources() corev1.ResourceRequirements { + return corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("512Mi"), + corev1.ResourceCPU: resource.MustParse("250m"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("2Gi"), + corev1.ResourceCPU: resource.MustParse("500m"), + }, + } +} + +// BuildMeilisearchStatefulSet creates the Meilisearch StatefulSet with persistent storage +func BuildMeilisearchStatefulSet(project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) *appsv1.StatefulSet { + spec := project.Spec.Meilisearch + name := MeilisearchStatefulSetName(project) + image := ResolveImage(spec.Image, defaults.MeilisearchImage, defaults.MeilisearchTag) + pullPolicy := ResolvePullPolicy(spec.Image) + replicas := NormalizeReplicas(spec.Replicas) + resources := normalizeMeilisearchResources(spec.Resources) + + // Storage configuration + storageSize := spec.Persistence.Size + if storageSize == "" { + storageSize = "10Gi" + } + + masterKeySecretName := secrets.MeilisearchSecretName(project) + + env := []corev1.EnvVar{ + {Name: "MEILI_ENV", Value: "production"}, + {Name: "MEILI_NO_ANALYTICS", Value: "true"}, + {Name: "MEILI_EXPERIMENTAL_LOGS_MODE", Value: "json"}, + {Name: "MEILI_DB_PATH", Value: "/meili_data/data.ms"}, + {Name: "MEILI_HTTP_ADDR", Value: "0.0.0.0:7700"}, + { + Name: "MEILI_MASTER_KEY", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: masterKeySecretName, + }, + Key: "masterKey", + }, + }, + }, + } + + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: project.Namespace, + Labels: common.ComponentLabels(project, MeilisearchComponentName), + }, + Spec: appsv1.StatefulSetSpec{ + ServiceName: name, + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: common.SelectorLabels(project, MeilisearchComponentName), + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: common.ComponentLabels(project, MeilisearchComponentName), + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: MeilisearchComponentName, + Image: image, + ImagePullPolicy: pullPolicy, + Env: env, + Ports: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: MeilisearchHTTPPort, + Protocol: corev1.ProtocolTCP, + }, + }, + LivenessProbe: BuildLivenessProbe("/health", MeilisearchHTTPPort), + ReadinessProbe: BuildReadinessProbe("/health", MeilisearchHTTPPort), + Resources: resources, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "data", + MountPath: "/meili_data", + }, + }, + }, + }, + }, + }, + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "data", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteOnce, + }, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse(storageSize), + }, + }, + }, + }, + }, + }, + } + + // Set storage class if specified + if spec.Persistence.StorageClass != "" { + sc := spec.Persistence.StorageClass + sts.Spec.VolumeClaimTemplates[0].Spec.StorageClassName = &sc + } + + AddImagePullSecrets(&sts.Spec.Template.Spec, project) + return sts +} + +func normalizeMeilisearchResources(resources corev1.ResourceRequirements) corev1.ResourceRequirements { + if len(resources.Requests) == 0 && len(resources.Limits) == 0 { + return DefaultMeilisearchResources() + } + return resources +} diff --git a/internal/resources/secrets/secrets.go b/internal/resources/secrets/secrets.go index e3315c0..de238eb 100644 --- a/internal/resources/secrets/secrets.go +++ b/internal/resources/secrets/secrets.go @@ -227,6 +227,43 @@ func PowersyncSecretNames(project *supabasev1alpha1.SupabaseProject) (powersyncS return project.Name + "-powersync-storage-password" } +// GenerateMeilisearchSecrets generates Meilisearch-related secrets +func GenerateMeilisearchSecrets(project *supabasev1alpha1.SupabaseProject) ([]*corev1.Secret, error) { + // Use existing secret if specified + if project.Spec.Meilisearch.MasterKeySecretRef != "" { + return nil, nil + } + + secretName := MeilisearchSecretName(project) + + masterKey, err := crypto.GenerateHex(32) + if err != nil { + return nil, fmt.Errorf("generating meilisearch master key: %w", err) + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: project.Namespace, + Labels: common.ComponentLabels(project, "meilisearch"), + }, + Type: corev1.SecretTypeOpaque, + StringData: map[string]string{ + "masterKey": masterKey, + }, + } + + return []*corev1.Secret{secret}, nil +} + +// MeilisearchSecretName returns the expected secret name for Meilisearch master key +func MeilisearchSecretName(project *supabasev1alpha1.SupabaseProject) string { + if project.Spec.Meilisearch != nil && project.Spec.Meilisearch.MasterKeySecretRef != "" { + return project.Spec.Meilisearch.MasterKeySecretRef + } + return project.Name + "-meilisearch-master-key" +} + // generateSequinAppSecret creates the Sequin application secret func generateSequinAppSecret(project *supabasev1alpha1.SupabaseProject) (*corev1.Secret, error) { secretName := project.Name + "-sequin" diff --git a/internal/resources/services/services.go b/internal/resources/services/services.go index 815d2ba..b6121d2 100644 --- a/internal/resources/services/services.go +++ b/internal/resources/services/services.go @@ -126,6 +126,11 @@ func BuildPowersyncAPIService(project *supabasev1alpha1.SupabaseProject) *corev1 } } +// BuildMeilisearchService creates the service for Meilisearch +func BuildMeilisearchService(project *supabasev1alpha1.SupabaseProject) *corev1.Service { + return BuildService(project, project.Name+"-meilisearch", "meilisearch", 7700) +} + // BuildKongService creates the service for Kong func BuildKongService(project *supabasev1alpha1.SupabaseProject) *corev1.Service { return &corev1.Service{ From bfc9329eda4abe17ed2ad39bf5d8a0b0992b61df Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 7 Feb 2026 20:19:47 +0800 Subject: [PATCH 06/21] feat(redis): add bundled Redis StatefulSet for Sequin - Add Redis 7 StatefulSet builder (AOF persistence, non-root, TCP+CLI probes) - Add Redis ClusterIP service builder - Add RedisPersistenceSpec and Redis resources/storage to RedisSpec CRD - Add redis:7 image defaults - Update Sequin deployment to auto-resolve bundled Redis URL when external is nil - Add reconcileSequinRedis to controller (deploys when external Redis not configured) - Matches flicknote-deploy Redis chart pattern (security context, probes, 2Gi storage) Co-Authored-By: Claude Opus 4.6 --- api/v1alpha1/supabaseproject_types.go | 27 ++- api/v1alpha1/zz_generated.deepcopy.go | 17 ++ .../supabase.guion.dev_supabaseprojects.yaml | 78 ++++++- .../controller/supabaseproject_controller.go | 37 +++- internal/resources/defaults/images.go | 4 + internal/resources/deployments/redis.go | 208 ++++++++++++++++++ internal/resources/deployments/sequin.go | 6 +- 7 files changed, 370 insertions(+), 7 deletions(-) create mode 100644 internal/resources/deployments/redis.go diff --git a/api/v1alpha1/supabaseproject_types.go b/api/v1alpha1/supabaseproject_types.go index 55b8f32..1b4ac01 100644 --- a/api/v1alpha1/supabaseproject_types.go +++ b/api/v1alpha1/supabaseproject_types.go @@ -589,11 +589,22 @@ type SequinSpec struct { Account *SequinAccountSpec `json:"account,omitempty"` } -// RedisSpec defines Redis configuration for Sequin +// RedisSpec defines Redis configuration for Sequin. +// If External is nil, the operator deploys a bundled single-replica Redis StatefulSet. type RedisSpec struct { - // External Redis reference (required for Phase 1) + // External Redis reference. If nil, operator deploys bundled Redis. // +optional External *ExternalRedisSpec `json:"external,omitempty"` + + // Resources for bundled Redis (default: 128Mi/50m request, 256Mi/200m limit) + // Ignored when External is set. + // +optional + Resources corev1.ResourceRequirements `json:"resources,omitempty"` + + // Storage for bundled Redis persistence (default: 2Gi) + // Ignored when External is set. + // +optional + Storage RedisPersistenceSpec `json:"storage,omitempty"` } // ExternalRedisSpec defines connection to an external Redis instance @@ -612,6 +623,18 @@ type ExternalRedisSpec struct { PasswordSecretRef string `json:"passwordSecretRef,omitempty"` } +// RedisPersistenceSpec defines Redis persistent storage configuration +type RedisPersistenceSpec struct { + // StorageClass (default: "" = cluster default) + // +optional + StorageClass string `json:"storageClass,omitempty"` + + // Size (default: 2Gi) + // +kubebuilder:default="2Gi" + // +optional + Size string `json:"size,omitempty"` +} + // SequinAccountSpec defines Sequin account/user configuration type SequinAccountSpec struct { // Account name (default: "default") diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 5bb4e8a..93ce67e 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -441,6 +441,21 @@ func (in *RecoverySpec) DeepCopy() *RecoverySpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RedisPersistenceSpec) DeepCopyInto(out *RedisPersistenceSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisPersistenceSpec. +func (in *RedisPersistenceSpec) DeepCopy() *RedisPersistenceSpec { + if in == nil { + return nil + } + out := new(RedisPersistenceSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RedisSpec) DeepCopyInto(out *RedisSpec) { *out = *in @@ -449,6 +464,8 @@ func (in *RedisSpec) DeepCopyInto(out *RedisSpec) { *out = new(ExternalRedisSpec) **out = **in } + in.Resources.DeepCopyInto(&out.Resources) + out.Storage = in.Storage } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisSpec. diff --git a/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml b/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml index 15d2221..bcb05a2 100644 --- a/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml +++ b/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml @@ -1451,8 +1451,8 @@ spec: for Phase 1 properties: external: - description: External Redis reference (required for Phase - 1) + description: External Redis reference. If nil, operator deploys + bundled Redis. properties: host: description: Host of external Redis instance @@ -1468,6 +1468,80 @@ spec: required: - host type: object + resources: + description: |- + Resources for bundled Redis (default: 128Mi/50m request, 256Mi/200m limit) + Ignored when External is set. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + storage: + description: |- + Storage for bundled Redis persistence (default: 2Gi) + Ignored when External is set. + properties: + size: + default: 2Gi + description: 'Size (default: 2Gi)' + type: string + storageClass: + description: 'StorageClass (default: "" = cluster default)' + type: string + type: object type: object replicas: default: 1 diff --git a/internal/controller/supabaseproject_controller.go b/internal/controller/supabaseproject_controller.go index cc9ab2b..e3f07a1 100644 --- a/internal/controller/supabaseproject_controller.go +++ b/internal/controller/supabaseproject_controller.go @@ -1179,13 +1179,20 @@ func (r *SupabaseProjectReconciler) reconcileCDCPermissions(ctx context.Context, return nil } -// reconcileSequin deploys the Sequin service +// reconcileSequin deploys the Sequin service (and bundled Redis if external is not configured) func (r *SupabaseProjectReconciler) reconcileSequin(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { log := logf.FromContext(ctx) log.Info("Reconciling Sequin service") secretNames := &project.Status.SecretNames + // Deploy bundled Redis if external is not configured + if project.Spec.Sequin.Redis.External == nil { + if err := r.reconcileSequinRedis(ctx, project); err != nil { + return err + } + } + // Create deployment deployment := deployments.BuildSequinDeployment(project, secretNames) if err := r.createOrUpdateDeployment(ctx, project, deployment); err != nil { @@ -1211,6 +1218,34 @@ func (r *SupabaseProjectReconciler) reconcileSequin(ctx context.Context, project return nil } +// reconcileSequinRedis deploys the bundled Redis StatefulSet and Service for Sequin +func (r *SupabaseProjectReconciler) reconcileSequinRedis(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { + log := logf.FromContext(ctx) + log.Info("Reconciling bundled Redis for Sequin") + + // Create Redis StatefulSet + sts := deployments.BuildSequinRedisStatefulSet(project) + if err := r.createOrUpdateStatefulSet(ctx, project, sts); err != nil { + r.setCondition(project, supabasev1alpha1.ConditionTypeSequinReady, metav1.ConditionFalse, "RedisStatefulSetFailed", err.Error()) + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return statusErr + } + return err + } + + // Create Redis Service + svc := deployments.BuildSequinRedisService(project) + if err := r.createOrUpdateService(ctx, project, svc); err != nil { + r.setCondition(project, supabasev1alpha1.ConditionTypeSequinReady, metav1.ConditionFalse, "RedisServiceFailed", err.Error()) + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return statusErr + } + return err + } + + return nil +} + // reconcilePowersync deploys the Powersync service (API + Replication + ConfigMaps + CronJob) func (r *SupabaseProjectReconciler) reconcilePowersync(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { log := logf.FromContext(ctx) diff --git a/internal/resources/defaults/images.go b/internal/resources/defaults/images.go index ef30afb..d5943fd 100644 --- a/internal/resources/defaults/images.go +++ b/internal/resources/defaults/images.go @@ -38,5 +38,9 @@ const ( MeilisearchImage = "getmeili/meilisearch" MeilisearchTag = "v1.11.0" + // Redis image defaults (bundled Redis for Sequin) + RedisImage = "redis" + RedisTag = "7" + // Note: CDC permissions Job uses the same PostgresImage for psql compatibility ) diff --git a/internal/resources/deployments/redis.go b/internal/resources/deployments/redis.go new file mode 100644 index 0000000..e4abfd6 --- /dev/null +++ b/internal/resources/deployments/redis.go @@ -0,0 +1,208 @@ +/* +Copyright 2026 GuionAI. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package deployments + +import ( + "fmt" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/utils/ptr" + + supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" + "github.com/GuionAI/cloudnative-supabase/internal/resources/common" + "github.com/GuionAI/cloudnative-supabase/internal/resources/defaults" +) + +const ( + RedisComponentName = "sequin-redis" + RedisPort int32 = 6379 +) + +// SequinRedisStatefulSetName returns the bundled Redis StatefulSet name +func SequinRedisStatefulSetName(project *supabasev1alpha1.SupabaseProject) string { + return project.Name + "-sequin-redis" +} + +// SequinRedisServiceName returns the bundled Redis service name +func SequinRedisServiceName(project *supabasev1alpha1.SupabaseProject) string { + return project.Name + "-sequin-redis" +} + +// DefaultRedisResources returns default resource requirements for bundled Redis +func DefaultRedisResources() corev1.ResourceRequirements { + return corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("128Mi"), + corev1.ResourceCPU: resource.MustParse("50m"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("256Mi"), + corev1.ResourceCPU: resource.MustParse("200m"), + }, + } +} + +// BuildSequinRedisStatefulSet creates a minimal single-replica Redis StatefulSet for Sequin. +// Matches the flicknote-deploy Redis chart: AOF persistence, non-root, TCP+CLI probes. +func BuildSequinRedisStatefulSet(project *supabasev1alpha1.SupabaseProject) *appsv1.StatefulSet { + spec := project.Spec.Sequin + name := SequinRedisStatefulSetName(project) + image := fmt.Sprintf("%s:%s", defaults.RedisImage, defaults.RedisTag) + + resources := normalizeRedisResources(spec.Redis.Resources) + + storageSize := spec.Redis.Storage.Size + if storageSize == "" { + storageSize = "2Gi" + } + + var replicas int32 = 1 + + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: project.Namespace, + Labels: common.ComponentLabels(project, RedisComponentName), + }, + Spec: appsv1.StatefulSetSpec{ + ServiceName: name, + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: common.SelectorLabels(project, RedisComponentName), + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: common.ComponentLabels(project, RedisComponentName), + }, + Spec: corev1.PodSpec{ + SecurityContext: &corev1.PodSecurityContext{ + RunAsNonRoot: ptr.To(true), + RunAsUser: ptr.To(int64(999)), + RunAsGroup: ptr.To(int64(1000)), + FSGroup: ptr.To(int64(1000)), + }, + Containers: []corev1.Container{ + { + Name: "redis", + Image: image, + SecurityContext: &corev1.SecurityContext{ + AllowPrivilegeEscalation: ptr.To(false), + Capabilities: &corev1.Capabilities{ + Drop: []corev1.Capability{"ALL"}, + }, + }, + Command: []string{"redis-server", "--appendonly", "yes"}, + Ports: []corev1.ContainerPort{ + { + Name: "redis", + ContainerPort: RedisPort, + Protocol: corev1.ProtocolTCP, + }, + }, + Resources: resources, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "data", + MountPath: "/data", + }, + }, + LivenessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + TCPSocket: &corev1.TCPSocketAction{ + Port: intstr.FromString("redis"), + }, + }, + InitialDelaySeconds: 30, + PeriodSeconds: 10, + }, + ReadinessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + Exec: &corev1.ExecAction{ + Command: []string{"redis-cli", "ping"}, + }, + }, + InitialDelaySeconds: 5, + PeriodSeconds: 5, + }, + }, + }, + }, + }, + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "data", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteOnce, + }, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse(storageSize), + }, + }, + }, + }, + }, + }, + } + + // Set storage class if specified + if spec.Redis.Storage.StorageClass != "" { + sc := spec.Redis.Storage.StorageClass + sts.Spec.VolumeClaimTemplates[0].Spec.StorageClassName = &sc + } + + AddImagePullSecrets(&sts.Spec.Template.Spec, project) + return sts +} + +// BuildSequinRedisService creates the ClusterIP service for bundled Redis +func BuildSequinRedisService(project *supabasev1alpha1.SupabaseProject) *corev1.Service { + name := SequinRedisServiceName(project) + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: project.Namespace, + Labels: common.ComponentLabels(project, RedisComponentName), + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Selector: common.SelectorLabels(project, RedisComponentName), + Ports: []corev1.ServicePort{ + { + Name: "redis", + Port: RedisPort, + TargetPort: intstr.FromString("redis"), + Protocol: corev1.ProtocolTCP, + }, + }, + }, + } +} + +func normalizeRedisResources(resources corev1.ResourceRequirements) corev1.ResourceRequirements { + if len(resources.Requests) == 0 && len(resources.Limits) == 0 { + return DefaultRedisResources() + } + return resources +} diff --git a/internal/resources/deployments/sequin.go b/internal/resources/deployments/sequin.go index 21f0922..b0adc6d 100644 --- a/internal/resources/deployments/sequin.go +++ b/internal/resources/deployments/sequin.go @@ -168,14 +168,16 @@ func BuildSequinDeployment(project *supabasev1alpha1.SupabaseProject, secretName func buildSequinEnv(project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus, dbHost string) []corev1.EnvVar { spec := project.Spec.Sequin - // Build Redis URL - redisURL := "redis://localhost:6379" + // Build Redis URL - external takes precedence, otherwise use bundled Redis service + var redisURL string if spec.Redis.External != nil { port := spec.Redis.External.Port if port == 0 { port = 6379 } redisURL = fmt.Sprintf("redis://%s:%d", spec.Redis.External.Host, port) + } else { + redisURL = fmt.Sprintf("redis://%s:%d", SequinRedisServiceName(project), RedisPort) } env := []corev1.EnvVar{ From 26c95de2058b45313bc1df139922a03edeed7d6d Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 7 Feb 2026 20:47:41 +0800 Subject: [PATCH 07/21] chore(chart): sync CRD, RBAC, and samples with new CDC/search services - Sync Helm chart CRD with generated spec (Sequin, Powersync, Meilisearch types) - Add StatefulSet, Job, and CronJob RBAC to Helm chart ClusterRole - Update sample CR with commented examples for optional services Co-Authored-By: Claude Opus 4.6 --- .../supabase.guion.dev_supabaseprojects.yaml | 613 ++++++++++++++++++ .../templates/clusterrole.yaml | 17 +- .../supabase_v1alpha1_supabaseproject.yaml | 40 +- 3 files changed, 660 insertions(+), 10 deletions(-) diff --git a/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml b/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml index acc3698..bcb05a2 100644 --- a/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml +++ b/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml @@ -878,6 +878,107 @@ spec: type: object type: object type: object + meilisearch: + description: Meilisearch full-text search configuration (optional + - presence enables Meilisearch) + properties: + image: + description: 'Image configuration (default: getmeili/meilisearch:v1.11.0)' + properties: + pullPolicy: + default: IfNotPresent + description: 'PullPolicy (default: IfNotPresent)' + type: string + registry: + description: 'Registry (default: docker.io)' + type: string + repository: + description: Repository (e.g., sequin/sequin) + type: string + tag: + description: Tag (pinned stable version per service) + type: string + type: object + masterKeySecretRef: + description: MasterKeySecretRef for existing secret (optional, + auto-generated if not provided) + type: string + persistence: + description: Persistence configuration + properties: + size: + default: 10Gi + description: 'Size (default: 10Gi)' + type: string + storageClass: + description: 'StorageClass (default: "" = cluster default)' + type: string + type: object + replicas: + default: 1 + description: 'Replicas (default: 1)' + format: int32 + type: integer + resources: + description: Resources for Meilisearch pods + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object meta: description: Meta service configuration (postgres-meta) properties: @@ -950,6 +1051,248 @@ spec: type: object type: object type: object + powersync: + description: Powersync offline-first sync configuration (optional + - presence enables Powersync) + properties: + api: + description: API deployment configuration (client-facing) + properties: + nodeOptions: + description: 'NodeOptions for heap size (default: "--max-old-space-size=330")' + type: string + replicas: + default: 2 + description: 'Replicas (default: 2)' + format: int32 + type: integer + resources: + description: Resources for Powersync API pods + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object + compact: + description: Compact CronJob configuration + properties: + enabled: + default: true + description: 'Enabled (default: true)' + type: boolean + resources: + description: Resources for compaction pods + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + schedule: + default: 0 3 * * * + description: 'Schedule in cron format (default: "0 3 * * *" + = 3am daily)' + type: string + type: object + image: + description: 'Image configuration (default: journeyapps/powersync-service:1.18.2)' + properties: + pullPolicy: + default: IfNotPresent + description: 'PullPolicy (default: IfNotPresent)' + type: string + registry: + description: 'Registry (default: docker.io)' + type: string + repository: + description: Repository (e.g., sequin/sequin) + type: string + tag: + description: Tag (pinned stable version per service) + type: string + type: object + replication: + description: Replication deployment configuration (CDC processing) + properties: + nodeOptions: + description: 'NodeOptions for heap size (default: "--max-old-space-size=482")' + type: string + resources: + description: Resources for Powersync replication pods + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object + syncRules: + description: Sync rules configuration + properties: + configMapRef: + description: Reference to external ConfigMap containing sync + rules (takes precedence over Inline) + type: string + inline: + description: Inline sync rules (YAML string) + type: string + type: object + type: object rest: description: Rest service configuration (PostgREST) properties: @@ -1071,6 +1414,200 @@ spec: rule: self.autoGenerate || (self.jwt.size() > 0 && self.supabaseAdmin.size() > 0 && self.authenticator.size() > 0 && self.authAdmin.size() > 0) + sequin: + description: Sequin CDC/event streaming configuration (optional - + presence enables Sequin) + properties: + account: + description: Account/user configuration + properties: + email: + description: 'Admin user email (default: "admin@example.com")' + type: string + name: + default: default + description: 'Account name (default: "default")' + type: string + type: object + image: + description: 'Image configuration (default: sequin/sequin:v0.13.25)' + properties: + pullPolicy: + default: IfNotPresent + description: 'PullPolicy (default: IfNotPresent)' + type: string + registry: + description: 'Registry (default: docker.io)' + type: string + repository: + description: Repository (e.g., sequin/sequin) + type: string + tag: + description: Tag (pinned stable version per service) + type: string + type: object + redis: + description: Redis configuration - external reference required + for Phase 1 + properties: + external: + description: External Redis reference. If nil, operator deploys + bundled Redis. + properties: + host: + description: Host of external Redis instance + type: string + passwordSecretRef: + description: PasswordSecretRef for Redis AUTH (optional) + type: string + port: + default: 6379 + description: 'Port (default: 6379)' + format: int32 + type: integer + required: + - host + type: object + resources: + description: |- + Resources for bundled Redis (default: 128Mi/50m request, 256Mi/200m limit) + Ignored when External is set. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + storage: + description: |- + Storage for bundled Redis persistence (default: 2Gi) + Ignored when External is set. + properties: + size: + default: 2Gi + description: 'Size (default: 2Gi)' + type: string + storageClass: + description: 'StorageClass (default: "" = cluster default)' + type: string + type: object + type: object + replicas: + default: 1 + description: 'Replicas (default: 1)' + format: int32 + type: integer + resources: + description: Resources for Sequin pods + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object studio: description: Studio dashboard configuration properties: @@ -1277,6 +1814,26 @@ spec: jwt: description: JWT is the name of the JWT secret type: string + meilisearchMasterKey: + description: MeilisearchMasterKey is the name of the Meilisearch + master key secret + type: string + powersyncStoragePassword: + description: PowersyncStoragePassword is the name of the powersync_storage + role password secret + type: string + sequin: + description: Sequin is the name of the Sequin secret (secretKeyBase, + vaultKey, apiToken) + type: string + sequinPassword: + description: SequinPassword is the name of the sequin database + role password secret + type: string + sequinReplicationPassword: + description: SequinReplicationPassword is the name of the sequin_replication + role password secret + type: string supabaseAdmin: description: SupabaseAdmin is the name of the supabase_admin password secret @@ -1313,6 +1870,20 @@ spec: required: - ready type: object + meilisearch: + description: ServiceStatus defines individual service status + properties: + availableReplicas: + description: AvailableReplicas is the number of available + replicas + format: int32 + type: integer + ready: + description: Ready indicates if the service is ready + type: boolean + required: + - ready + type: object meta: description: ServiceStatus defines individual service status properties: @@ -1327,6 +1898,34 @@ spec: required: - ready type: object + powersyncApi: + description: ServiceStatus defines individual service status + properties: + availableReplicas: + description: AvailableReplicas is the number of available + replicas + format: int32 + type: integer + ready: + description: Ready indicates if the service is ready + type: boolean + required: + - ready + type: object + powersyncReplication: + description: ServiceStatus defines individual service status + properties: + availableReplicas: + description: AvailableReplicas is the number of available + replicas + format: int32 + type: integer + ready: + description: Ready indicates if the service is ready + type: boolean + required: + - ready + type: object rest: description: ServiceStatus defines individual service status properties: @@ -1341,6 +1940,20 @@ spec: required: - ready type: object + sequin: + description: ServiceStatus defines individual service status + properties: + availableReplicas: + description: AvailableReplicas is the number of available + replicas + format: int32 + type: integer + ready: + description: Ready indicates if the service is ready + type: boolean + required: + - ready + type: object studio: description: ServiceStatus defines individual service status properties: diff --git a/charts/cloudnative-supabase/templates/clusterrole.yaml b/charts/cloudnative-supabase/templates/clusterrole.yaml index 8cf0848..46ce0f4 100644 --- a/charts/cloudnative-supabase/templates/clusterrole.yaml +++ b/charts/cloudnative-supabase/templates/clusterrole.yaml @@ -20,11 +20,26 @@ rules: - patch - update - watch - # Deployments + # Deployments and StatefulSets - apiGroups: - apps resources: - deployments + - statefulsets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + # Jobs and CronJobs (CDC permissions, Powersync compact) + - apiGroups: + - batch + resources: + - cronjobs + - jobs verbs: - create - delete diff --git a/config/samples/supabase_v1alpha1_supabaseproject.yaml b/config/samples/supabase_v1alpha1_supabaseproject.yaml index 5656929..81ac25a 100644 --- a/config/samples/supabase_v1alpha1_supabaseproject.yaml +++ b/config/samples/supabase_v1alpha1_supabaseproject.yaml @@ -13,15 +13,6 @@ spec: size: 10Gi storageClass: local-path enableSuperuserAccess: false - # Additional roles for CDC tools like Sequin/PowerSync - # Uses CNPG RoleConfiguration format directly - # additionalRoles: - # - name: sequin_replication - # login: true - # replication: true - # bypassRLS: true - # passwordSecret: - # name: sequin-replication-password auth: siteURL: https://app.example.com @@ -45,3 +36,34 @@ spec: projectName: Example Project # meta and kong use defaults (always enabled) + + # --- Optional CDC/Search Services --- + # Uncomment sections below to enable. Presence = enabled, absence = disabled. + # All secrets, roles, and permissions are auto-generated. + + # Sequin CDC/event streaming (bundled Redis auto-deployed) + # sequin: {} + + # Sequin with external Redis + # sequin: + # replicas: 1 + # redis: + # external: + # host: redis.infra.svc + # port: 6379 + + # Powersync offline-first sync + # powersync: + # api: + # replicas: 2 + # syncRules: + # inline: | + # bucket_definitions: + # global: + # data: + # - SELECT * FROM public.* + + # Meilisearch full-text search + # meilisearch: + # persistence: + # size: 10Gi From 98834bdd45fd2fdf4028054410c26b1849ff91c4 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 7 Feb 2026 21:01:18 +0800 Subject: [PATCH 08/21] chore(chart): remove stale CRD file Co-Authored-By: Claude Opus 4.6 --- .../crds/supabaseprojects.yaml | 1300 ----------------- 1 file changed, 1300 deletions(-) delete mode 100644 charts/cloudnative-supabase/crds/supabaseprojects.yaml diff --git a/charts/cloudnative-supabase/crds/supabaseprojects.yaml b/charts/cloudnative-supabase/crds/supabaseprojects.yaml deleted file mode 100644 index d912df2..0000000 --- a/charts/cloudnative-supabase/crds/supabaseprojects.yaml +++ /dev/null @@ -1,1300 +0,0 @@ ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.19.0 - name: supabaseprojects.supabase.guion.dev -spec: - group: supabase.guion.dev - names: - kind: SupabaseProject - listKind: SupabaseProjectList - plural: supabaseprojects - singular: supabaseproject - scope: Namespaced - versions: - - additionalPrinterColumns: - - description: Current phase - jsonPath: .status.phase - name: Phase - type: string - - description: Database ready - jsonPath: .status.database.ready - name: Database - type: boolean - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1alpha1 - schema: - openAPIV3Schema: - description: SupabaseProject is the Schema for the supabaseprojects API - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: SupabaseProjectSpec defines the desired state of SupabaseProject - properties: - auth: - description: Auth service configuration (GoTrue) - properties: - autoConfirmEmail: - default: true - description: AutoConfirmEmail enables automatic email confirmation - type: boolean - disableSignup: - default: false - description: DisableSignup prevents new user registrations - type: boolean - emailHook: - description: EmailHook for custom email sending - properties: - enabled: - description: Enabled enables the email hook - type: boolean - uri: - description: URI is the webhook endpoint for email sending - type: string - required: - - enabled - - uri - type: object - externalURL: - description: ExternalURL is the public URL of the auth service - type: string - imageTag: - default: v2.184.0 - description: Image tag for supabase/gotrue - type: string - providers: - description: Providers configuration for OAuth - properties: - apple: - description: Apple Sign-In configuration - properties: - enabled: - description: Enabled enables Apple Sign-In - type: boolean - required: - - enabled - type: object - google: - description: Google OAuth configuration - properties: - enabled: - description: Enabled enables Google OAuth - type: boolean - skipNonceCheck: - description: SkipNonceCheck for Google One Tap - type: boolean - required: - - enabled - type: object - secretRef: - description: |- - SecretRef for provider credentials (contains env vars like GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID) - Required when Google or Apple provider is enabled. - type: string - type: object - x-kubernetes-validations: - - message: secretRef is required when Google or Apple provider - is enabled - rule: ((!has(self.google) || !self.google.enabled) && (!has(self.apple) - || !self.apple.enabled)) || self.secretRef.size() > 0 - replicas: - default: 1 - description: Replicas count - format: int32 - type: integer - resources: - description: Resources for Auth pods - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This field depends on the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - request: - description: |- - Request is the name chosen for a request in the referenced claim. - If empty, everything from the claim is made available, otherwise - only the result of this request. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - siteURL: - description: SiteURL is the public URL of your application - type: string - smtp: - description: SMTP configuration - properties: - adminEmail: - description: AdminEmail is the sender email address - type: string - host: - description: Host is the SMTP server hostname - type: string - port: - description: Port is the SMTP server port - type: integer - secretRef: - description: SecretRef references a secret containing the - password key - type: string - senderName: - description: SenderName is the display name for emails - type: string - user: - description: User is the SMTP username - type: string - required: - - adminEmail - - host - - port - - secretRef - - senderName - - user - type: object - required: - - externalURL - - siteURL - type: object - database: - description: Database configuration for CNPG PostgreSQL cluster - properties: - additionalExtensions: - description: AdditionalExtensions beyond the standard Supabase - set - items: - type: string - type: array - additionalRoles: - description: |- - AdditionalRoles beyond the standard Supabase roles (e.g., sequin_replication) - Uses CNPG RoleConfiguration directly for full compatibility - items: - description: |- - RoleConfiguration is the representation, in Kubernetes, of a PostgreSQL role - with the additional field Ensure specifying whether to ensure the presence or - absence of the role in the database - - The defaults of the CREATE ROLE command are applied - Reference: https://www.postgresql.org/docs/current/sql-createrole.html - properties: - bypassrls: - description: |- - Whether a role bypasses every row-level security (RLS) policy. - Default is `false`. - type: boolean - comment: - description: Description of the role - type: string - connectionLimit: - default: -1 - description: |- - If the role can log in, this specifies how many concurrent - connections the role can make. `-1` (the default) means no limit. - format: int64 - type: integer - createdb: - description: |- - When set to `true`, the role being defined will be allowed to create - new databases. Specifying `false` (default) will deny a role the - ability to create databases. - type: boolean - createrole: - description: |- - Whether the role will be permitted to create, alter, drop, comment - on, change the security label for, and grant or revoke membership in - other roles. Default is `false`. - type: boolean - disablePassword: - description: DisablePassword indicates that a role's password - should be set to NULL in Postgres - type: boolean - ensure: - default: present - description: Ensure the role is `present` or `absent` - - defaults to "present" - enum: - - present - - absent - type: string - inRoles: - description: |- - List of one or more existing roles to which this role will be - immediately added as a new member. Default empty. - items: - type: string - type: array - inherit: - default: true - description: |- - Whether a role "inherits" the privileges of roles it is a member of. - Defaults is `true`. - type: boolean - login: - description: |- - Whether the role is allowed to log in. A role having the `login` - attribute can be thought of as a user. Roles without this attribute - are useful for managing database privileges, but are not users in - the usual sense of the word. Default is `false`. - type: boolean - name: - description: Name of the role - type: string - passwordSecret: - description: |- - Secret containing the password of the role (if present) - If null, the password will be ignored unless DisablePassword is set - properties: - name: - description: Name of the referent. - type: string - required: - - name - type: object - replication: - description: |- - Whether a role is a replication role. A role must have this - attribute (or be a superuser) in order to be able to connect to the - server in replication mode (physical or logical replication) and in - order to be able to create or drop replication slots. A role having - the `replication` attribute is a very highly privileged role, and - should only be used on roles actually used for replication. Default - is `false`. - type: boolean - superuser: - description: |- - Whether the role is a `superuser` who can override all access - restrictions within the database - superuser status is dangerous and - should be used only when really needed. You must yourself be a - superuser to create a new superuser. Defaults is `false`. - type: boolean - validUntil: - description: |- - Date and time after which the role's password is no longer valid. - When omitted, the password will never expire (default). - format: date-time - type: string - required: - - name - type: object - type: array - backup: - description: Backup configuration - properties: - destinationPath: - description: |- - DestinationPath is the S3/R2 bucket path (s3://bucket/path/) - Required when backup is enabled - type: string - enabled: - default: false - description: Enabled enables scheduled backups - type: boolean - endpointURL: - description: EndpointURL for S3-compatible storage - type: string - retentionPolicy: - default: 30d - description: RetentionPolicy defines how long to keep backups - type: string - s3CredentialsSecret: - description: |- - S3CredentialsSecret references a secret with ACCESS_KEY_ID and SECRET_ACCESS_KEY - Required when backup is enabled - type: string - schedule: - default: 0 0 2 * * * - description: Schedule in cron format (6 fields including seconds) - type: string - required: - - enabled - type: object - x-kubernetes-validations: - - message: destinationPath is required when backup is enabled - rule: '!self.enabled || self.destinationPath.size() > 0' - - message: s3CredentialsSecret is required when backup is enabled - rule: '!self.enabled || self.s3CredentialsSecret.size() > 0' - enableSuperuserAccess: - default: false - description: EnableSuperuserAccess allows connecting as postgres - superuser - type: boolean - image: - description: 'Image is the PostgreSQL image (default: ghcr.io/cloudnative-pg/postgresql:17)' - type: string - instances: - default: 1 - description: Instances is the number of PostgreSQL instances - format: int32 - maximum: 10 - minimum: 1 - type: integer - parameters: - additionalProperties: - type: string - description: Parameters for PostgreSQL configuration - type: object - resources: - description: Resources for PostgreSQL pods - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This field depends on the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - request: - description: |- - Request is the name chosen for a request in the referenced claim. - If empty, everything from the claim is made available, otherwise - only the result of this request. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - storage: - description: Storage configuration (uses CNPG StorageConfiguration - directly) - properties: - pvcTemplate: - description: Template to be used to generate the Persistent - Volume Claim - properties: - accessModes: - description: |- - accessModes contains the desired access modes the volume should have. - More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1 - items: - type: string - type: array - x-kubernetes-list-type: atomic - dataSource: - description: |- - dataSource field can be used to specify either: - * An existing VolumeSnapshot object (snapshot.storage.k8s.io/VolumeSnapshot) - * An existing PVC (PersistentVolumeClaim) - If the provisioner or an external controller can support the specified data source, - it will create a new volume based on the contents of the specified data source. - When the AnyVolumeDataSource feature gate is enabled, dataSource contents will be copied to dataSourceRef, - and dataSourceRef contents will be copied to dataSource when dataSourceRef.namespace is not specified. - If the namespace is specified, then dataSourceRef will not be copied to dataSource. - properties: - apiGroup: - description: |- - APIGroup is the group for the resource being referenced. - If APIGroup is not specified, the specified Kind must be in the core API group. - For any other third-party types, APIGroup is required. - type: string - kind: - description: Kind is the type of resource being referenced - type: string - name: - description: Name is the name of resource being referenced - type: string - required: - - kind - - name - type: object - x-kubernetes-map-type: atomic - dataSourceRef: - description: |- - dataSourceRef specifies the object from which to populate the volume with data, if a non-empty - volume is desired. This may be any object from a non-empty API group (non - core object) or a PersistentVolumeClaim object. - When this field is specified, volume binding will only succeed if the type of - the specified object matches some installed volume populator or dynamic - provisioner. - This field will replace the functionality of the dataSource field and as such - if both fields are non-empty, they must have the same value. For backwards - compatibility, when namespace isn't specified in dataSourceRef, - both fields (dataSource and dataSourceRef) will be set to the same - value automatically if one of them is empty and the other is non-empty. - When namespace is specified in dataSourceRef, - dataSource isn't set to the same value and must be empty. - There are three important differences between dataSource and dataSourceRef: - * While dataSource only allows two specific types of objects, dataSourceRef - allows any non-core object, as well as PersistentVolumeClaim objects. - * While dataSource ignores disallowed values (dropping them), dataSourceRef - preserves all values, and generates an error if a disallowed value is - specified. - * While dataSource only allows local objects, dataSourceRef allows objects - in any namespaces. - (Beta) Using this field requires the AnyVolumeDataSource feature gate to be enabled. - (Alpha) Using the namespace field of dataSourceRef requires the CrossNamespaceVolumeDataSource feature gate to be enabled. - properties: - apiGroup: - description: |- - APIGroup is the group for the resource being referenced. - If APIGroup is not specified, the specified Kind must be in the core API group. - For any other third-party types, APIGroup is required. - type: string - kind: - description: Kind is the type of resource being referenced - type: string - name: - description: Name is the name of resource being referenced - type: string - namespace: - description: |- - Namespace is the namespace of resource being referenced - Note that when a namespace is specified, a gateway.networking.k8s.io/ReferenceGrant object is required in the referent namespace to allow that namespace's owner to accept the reference. See the ReferenceGrant documentation for details. - (Alpha) This field requires the CrossNamespaceVolumeDataSource feature gate to be enabled. - type: string - required: - - kind - - name - type: object - resources: - description: |- - resources represents the minimum resources the volume should have. - If RecoverVolumeExpansionFailure feature is enabled users are allowed to specify resource requirements - that are lower than previous value but must still be higher than capacity recorded in the - status field of the claim. - More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources - properties: - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - selector: - description: selector is a label query over volumes to - consider for binding. - properties: - matchExpressions: - description: matchExpressions is a list of label selector - requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector - applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - storageClassName: - description: |- - storageClassName is the name of the StorageClass required by the claim. - More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1 - type: string - volumeAttributesClassName: - description: |- - volumeAttributesClassName may be used to set the VolumeAttributesClass used by this claim. - If specified, the CSI driver will create or update the volume with the attributes defined - in the corresponding VolumeAttributesClass. This has a different purpose than storageClassName, - it can be changed after the claim is created. An empty string or nil value indicates that no - VolumeAttributesClass will be applied to the claim. If the claim enters an Infeasible error state, - this field can be reset to its previous value (including nil) to cancel the modification. - If the resource referred to by volumeAttributesClass does not exist, this PersistentVolumeClaim will be - set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource - exists. - More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/ - type: string - volumeMode: - description: |- - volumeMode defines what type of volume is required by the claim. - Value of Filesystem is implied when not included in claim spec. - type: string - volumeName: - description: volumeName is the binding reference to the - PersistentVolume backing this claim. - type: string - type: object - resizeInUseVolumes: - default: true - description: Resize existent PVCs, defaults to true - type: boolean - size: - description: |- - Size of the storage. Required if not already specified in the PVC template. - Changes to this field are automatically reapplied to the created PVCs. - Size cannot be decreased. - type: string - storageClass: - description: |- - StorageClass to use for PVCs. Applied after - evaluating the PVC template, if available. - If not specified, the generated PVCs will use the - default storage class - type: string - type: object - required: - - instances - - storage - type: object - imagePullSecrets: - description: ImagePullSecrets for all deployments - items: - description: |- - LocalObjectReference contains enough information to let you locate the - referenced object inside the same namespace. - properties: - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - type: object - x-kubernetes-map-type: atomic - type: array - jwt: - description: |- - JWT configuration (auto-generated if not provided) - Deprecated: Use secrets.jwt instead - properties: - expirationSeconds: - default: 3600 - description: ExpirationSeconds for generated tokens - type: integer - secretRef: - description: |- - SecretRef references an existing JWT secret - If not provided, a secret will be auto-generated - type: string - type: object - kong: - description: Kong API gateway configuration - properties: - imageTag: - default: 2.8.1 - description: ImageTag for kong - type: string - ingress: - description: Ingress configuration - properties: - annotations: - additionalProperties: - type: string - description: Annotations for the ingress - type: object - className: - description: ClassName is the ingress class name - type: string - enabled: - description: Enabled enables ingress creation - type: boolean - host: - description: Host is the ingress hostname (required when enabled) - type: string - tls: - description: TLS enables TLS termination - type: boolean - tlsSecretName: - description: TLSSecretName is the secret containing TLS certificate - type: string - required: - - enabled - type: object - x-kubernetes-validations: - - message: host is required when ingress is enabled - rule: '!self.enabled || self.host.size() > 0' - replicas: - default: 1 - description: Replicas count - format: int32 - type: integer - resources: - description: Resources for Kong pods - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This field depends on the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - request: - description: |- - Request is the name chosen for a request in the referenced claim. - If empty, everything from the claim is made available, otherwise - only the result of this request. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - type: object - meta: - description: Meta service configuration (postgres-meta) - properties: - imageTag: - default: v0.84.2 - description: ImageTag for supabase/postgres-meta - type: string - replicas: - default: 1 - description: Replicas count - format: int32 - type: integer - resources: - description: Resources for Meta pods - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This field depends on the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - request: - description: |- - Request is the name chosen for a request in the referenced claim. - If empty, everything from the claim is made available, otherwise - only the result of this request. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - type: object - rest: - description: Rest service configuration (PostgREST) - properties: - imageTag: - default: v12.2.3 - description: ImageTag for postgrest/postgrest - type: string - replicas: - default: 1 - description: Replicas count - format: int32 - type: integer - resources: - description: Resources for PostgREST pods - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This field depends on the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - request: - description: |- - Request is the name chosen for a request in the referenced claim. - If empty, everything from the claim is made available, otherwise - only the result of this request. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - schemas: - default: - - public - description: Schemas exposed via the API - items: - type: string - type: array - type: object - secrets: - description: |- - Secrets configuration for migration support - When autoGenerate is false, user must provide all secret references - properties: - authAdmin: - description: |- - AuthAdmin references an existing secret containing 'username' and 'password' keys - for the supabase_auth_admin database role (used by GoTrue). - Required when autoGenerate is false. - type: string - authenticator: - description: |- - Authenticator references an existing secret containing 'username' and 'password' keys - for the authenticator database role (used by PostgREST). - Required when autoGenerate is false. - type: string - autoGenerate: - default: true - description: |- - AutoGenerate controls whether the operator generates secrets automatically. - Set to false when migrating from an existing cluster with pre-existing secrets. - type: boolean - jwt: - description: |- - JWT references an existing JWT secret containing 'secret', 'anonKey', and 'serviceKey' keys. - Required when autoGenerate is false. - type: string - supabaseAdmin: - description: |- - SupabaseAdmin references an existing secret containing 'username' and 'password' keys - for the supabase_admin database role. - Required when autoGenerate is false. - type: string - required: - - autoGenerate - type: object - x-kubernetes-validations: - - message: all secret refs are required when autoGenerate is false - rule: self.autoGenerate || (self.jwt.size() > 0 && self.supabaseAdmin.size() - > 0 && self.authenticator.size() > 0 && self.authAdmin.size() - > 0) - studio: - description: Studio dashboard configuration - properties: - imageTag: - default: 2024.12.09-sha-434634f - description: ImageTag for supabase/studio - type: string - organizationName: - default: Default Organization - description: OrganizationName shown in Studio - type: string - projectName: - default: Default Project - description: ProjectName shown in Studio - type: string - publicURL: - description: PublicURL is the external URL for Studio - type: string - replicas: - default: 1 - description: Replicas count - format: int32 - type: integer - resources: - description: Resources for Studio pods - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This field depends on the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - request: - description: |- - Request is the name chosen for a request in the referenced claim. - If empty, everything from the claim is made available, otherwise - only the result of this request. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - type: object - required: - - auth - - database - type: object - status: - description: SupabaseProjectStatus defines the observed state of SupabaseProject - properties: - conditions: - description: Conditions represent the latest available observations - items: - description: Condition contains details for one aspect of the current - state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - database: - description: Database status - properties: - phase: - description: Phase of the CNPG cluster - type: string - primaryHost: - description: PrimaryHost is the primary pod hostname - type: string - ready: - description: Ready indicates if the database cluster is ready - type: boolean - readyInstances: - description: ReadyInstances is the number of ready instances - format: int32 - type: integer - required: - - ready - type: object - endpoints: - description: Endpoints contains service endpoints - properties: - api: - description: API is the Kong gateway endpoint (internal) - type: string - database: - description: Database is the PostgreSQL connection endpoint - type: string - type: object - observedGeneration: - description: ObservedGeneration is the last observed generation - format: int64 - type: integer - phase: - description: Phase represents the current lifecycle phase - enum: - - Pending - - Provisioning - - Running - - Failed - - Deleting - type: string - secretNames: - description: SecretNames contains the names of generated secrets - properties: - authAdmin: - description: AuthAdmin is the name of the supabase_auth_admin - password secret - type: string - authenticator: - description: Authenticator is the name of the authenticator password - secret - type: string - jwt: - description: JWT is the name of the JWT secret - type: string - supabaseAdmin: - description: SupabaseAdmin is the name of the supabase_admin password - secret - type: string - type: object - services: - description: Services status - properties: - auth: - description: ServiceStatus defines individual service status - properties: - availableReplicas: - description: AvailableReplicas is the number of available - replicas - format: int32 - type: integer - ready: - description: Ready indicates if the service is ready - type: boolean - required: - - ready - type: object - kong: - description: ServiceStatus defines individual service status - properties: - availableReplicas: - description: AvailableReplicas is the number of available - replicas - format: int32 - type: integer - ready: - description: Ready indicates if the service is ready - type: boolean - required: - - ready - type: object - meta: - description: ServiceStatus defines individual service status - properties: - availableReplicas: - description: AvailableReplicas is the number of available - replicas - format: int32 - type: integer - ready: - description: Ready indicates if the service is ready - type: boolean - required: - - ready - type: object - rest: - description: ServiceStatus defines individual service status - properties: - availableReplicas: - description: AvailableReplicas is the number of available - replicas - format: int32 - type: integer - ready: - description: Ready indicates if the service is ready - type: boolean - required: - - ready - type: object - studio: - description: ServiceStatus defines individual service status - properties: - availableReplicas: - description: AvailableReplicas is the number of available - replicas - format: int32 - type: integer - ready: - description: Ready indicates if the service is ready - type: boolean - required: - - ready - type: object - type: object - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} From 3e627dc922ef86f04c63ac52f974a6c22f119bde Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 7 Feb 2026 21:12:57 +0800 Subject: [PATCH 09/21] test(resources): add unit tests for CDC/search resource builders Add 50 tests across 7 test files covering all new resource builders: - Sequin: deployment, env vars, external/bundled Redis URL resolution - Redis: StatefulSet, service, storage customization, security context - Powersync: API/Replication deployments, CronJob, env vars, volumes - Meilisearch: StatefulSet, PVC, master key secret ref, image pull secrets - Secrets: generation for Sequin/Powersync/Meilisearch, uniqueness - ConfigMaps: Powersync config JSON structure, sync rules (inline/external/default) - Services: port validation, labels/selectors for all new services Also add CDC & Search quick start documentation. Co-Authored-By: Claude Opus 4.6 --- docs/cdc-search-quickstart.md | 278 +++++++++++++++ .../resources/configmaps/powersync_test.go | 174 ++++++++++ .../resources/deployments/meilisearch_test.go | 194 +++++++++++ .../resources/deployments/powersync_test.go | 235 +++++++++++++ internal/resources/deployments/redis_test.go | 157 +++++++++ internal/resources/deployments/sequin_test.go | 320 ++++++++++++++++++ internal/resources/secrets/secrets_test.go | 214 ++++++++++++ internal/resources/services/services_test.go | 134 ++++++++ 8 files changed, 1706 insertions(+) create mode 100644 docs/cdc-search-quickstart.md create mode 100644 internal/resources/configmaps/powersync_test.go create mode 100644 internal/resources/deployments/meilisearch_test.go create mode 100644 internal/resources/deployments/powersync_test.go create mode 100644 internal/resources/deployments/redis_test.go create mode 100644 internal/resources/deployments/sequin_test.go create mode 100644 internal/resources/secrets/secrets_test.go create mode 100644 internal/resources/services/services_test.go diff --git a/docs/cdc-search-quickstart.md b/docs/cdc-search-quickstart.md new file mode 100644 index 0000000..1af1576 --- /dev/null +++ b/docs/cdc-search-quickstart.md @@ -0,0 +1,278 @@ +# CDC & Search Services Quick Start + +This guide covers enabling optional CDC (Change Data Capture) and search services in your SupabaseProject. + +## Overview + +Three optional services can be enabled by adding their spec section to your SupabaseProject: + +| Service | Purpose | Trigger | +|---------|---------|---------| +| **Sequin** | CDC/event streaming | `spec.sequin` present | +| **Powersync** | Offline-first sync for mobile/web | `spec.powersync` present | +| **Meilisearch** | Full-text search engine | `spec.meilisearch` present | + +All services are optional. Omitting a section means that service is not deployed. + +## Minimal Setup + +Enable all three services with defaults: + +```yaml +apiVersion: supabase.guion.dev/v1alpha1 +kind: SupabaseProject +metadata: + name: my-app + namespace: my-app +spec: + database: + instances: 1 + storage: + size: 10Gi + + auth: + siteURL: https://app.example.com + externalURL: https://auth.example.com + + # CDC + Search - just add the section to enable + sequin: {} + powersync: {} + meilisearch: {} +``` + +This gives you: +- Sequin with bundled Redis (2Gi AOF persistence) +- Powersync with default sync rules and daily compaction +- Meilisearch with 10Gi storage + +All secrets, database roles, and CDC publications are auto-configured. + +## Sequin Configuration + +### With Bundled Redis (default) + +```yaml +sequin: {} +``` + +The operator deploys a single-replica Redis StatefulSet with: +- AOF persistence (`--appendonly yes`) +- 2Gi default storage +- Non-root security context + +### With External Redis + +```yaml +sequin: + redis: + external: + host: redis.infra.svc + port: 6379 +``` + +### Full Configuration + +```yaml +sequin: + image: + registry: ghcr.io + repository: guionai/sequin + tag: flicknote + replicas: 2 + resources: + requests: + memory: 512Mi + cpu: 200m + limits: + memory: 1Gi + cpu: 1 + redis: + external: + host: redis.infra.svc + port: 6379 +``` + +### Bundled Redis Customization + +```yaml +sequin: + redis: + resources: + requests: + memory: 256Mi + limits: + memory: 512Mi + storage: + size: 5Gi + storageClass: fast-ssd +``` + +## Powersync Configuration + +### Default Setup + +```yaml +powersync: {} +``` + +Creates: +- **API deployment** (1 replica) - client-facing sync endpoint +- **Replication deployment** (1 replica) - CDC processing +- **Compact CronJob** - daily at 3am +- **Default sync rules** - `SELECT * FROM public.*` + +### Custom Sync Rules (inline) + +```yaml +powersync: + syncRules: + inline: | + bucket_definitions: + user_data: + parameters: SELECT token_parameters.user_id as user_id + data: + - SELECT * FROM todos WHERE user_id = bucket.user_id +``` + +### External Sync Rules ConfigMap + +```yaml +powersync: + syncRules: + configMapRef: my-sync-rules +``` + +The referenced ConfigMap must have a `sync_rules.yaml` key. + +### Full Configuration + +```yaml +powersync: + image: + repository: journeyapps/powersync-service + tag: "1.18.2" + api: + replicas: 3 + resources: + requests: + memory: 512Mi + cpu: 200m + limits: + memory: 1Gi + cpu: 2 + nodeOptions: "--max-old-space-size=512" + replication: + resources: + requests: + memory: 1Gi + limits: + memory: 2Gi + nodeOptions: "--max-old-space-size=960" + compact: + enabled: true + schedule: "0 2 * * *" +``` + +## Meilisearch Configuration + +### Default Setup + +```yaml +meilisearch: {} +``` + +Creates a StatefulSet with 10Gi persistent storage and auto-generated master key. + +### With Existing Master Key + +```yaml +meilisearch: + masterKeySecretRef: my-meili-key +``` + +The secret must have a `masterKey` key. + +### Full Configuration + +```yaml +meilisearch: + image: + repository: getmeili/meilisearch + tag: v1.12.0 + replicas: 1 + persistence: + size: 50Gi + storageClass: longhorn + resources: + requests: + memory: 1Gi + cpu: 500m + limits: + memory: 4Gi + cpu: 2 +``` + +## Auto-Generated Resources + +### Secrets + +| Secret | Keys | Generated When | +|--------|------|----------------| +| `-sequin` | `secretKeyBase`, `vaultKey`, `apiToken` | `spec.sequin` present | +| `-sequin-password` | `username`, `password` | `spec.sequin` present | +| `-sequin-replication-password` | `username`, `password` | `spec.sequin` or `spec.powersync` present | +| `-powersync-storage-password` | `username`, `password` | `spec.powersync` present | +| `-meilisearch-master-key` | `masterKey` | `spec.meilisearch` present (unless `masterKeySecretRef` set) | + +Secrets are only generated if they don't already exist in the cluster, preventing regeneration on operator restart. + +### Database Roles + +| Role | Created When | Capabilities | +|------|-------------|-------------| +| `sequin` | `spec.sequin` present | Login, owns `sequin` database | +| `sequin_replication` | `spec.sequin` or `spec.powersync` | Login, replication, bypassrls | +| `powersync_storage` | `spec.powersync` present | Login | + +### CDC Permissions Job + +When Sequin or Powersync is enabled, a Kubernetes Job runs after the database is ready to grant CDC permissions: +- `USAGE` on `public` schema to `sequin_replication` +- `SELECT` on future tables in `public` schema +- `CREATE` on database for Sequin migrations + +## Status Conditions + +Monitor deployment progress via status conditions: + +```bash +kubectl get supabaseproject my-app -o jsonpath='{.status.conditions}' | jq . +``` + +| Condition | Description | +|-----------|-------------| +| `CDCReady` | CDC permissions applied | +| `SequinReady` | Sequin deployment available | +| `PowersyncReady` | Powersync deployments available | +| `MeilisearchReady` | Meilisearch StatefulSet ready | + +## Troubleshooting + +### Sequin not starting +Check that the bundled Redis or external Redis is reachable: +```bash +kubectl logs deploy/-sequin +``` + +### Powersync replication errors +Verify CDC permissions were applied: +```bash +kubectl get job -l app.kubernetes.io/component=cdc-permissions +kubectl logs job/-cdc-permissions +``` + +### Meilisearch data persistence +Meilisearch uses a StatefulSet with PVC. Data survives pod restarts. Check PVC status: +```bash +kubectl get pvc -l app.kubernetes.io/component=meilisearch +``` diff --git a/internal/resources/configmaps/powersync_test.go b/internal/resources/configmaps/powersync_test.go new file mode 100644 index 0000000..af06158 --- /dev/null +++ b/internal/resources/configmaps/powersync_test.go @@ -0,0 +1,174 @@ +package configmaps + +import ( + "encoding/json" + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" +) + +func newTestProject(name, namespace string) *supabasev1alpha1.SupabaseProject { + return &supabasev1alpha1.SupabaseProject{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: supabasev1alpha1.SupabaseProjectSpec{ + Powersync: &supabasev1alpha1.PowersyncSpec{}, + }, + } +} + +func TestPowersyncConfigMapName(t *testing.T) { + project := newTestProject("my-app", "default") + got := PowersyncConfigMapName(project) + if got != "my-app-powersync-config" { + t.Errorf("PowersyncConfigMapName() = %q, want %q", got, "my-app-powersync-config") + } +} + +func TestPowersyncSyncRulesConfigMapName(t *testing.T) { + project := newTestProject("my-app", "default") + got := PowersyncSyncRulesConfigMapName(project) + if got != "my-app-powersync-sync-rules" { + t.Errorf("PowersyncSyncRulesConfigMapName() = %q, want %q", got, "my-app-powersync-sync-rules") + } +} + +func TestSyncRulesConfigMapName(t *testing.T) { + tests := []struct { + name string + configMapRef string + want string + }{ + { + name: "auto-generated", + configMapRef: "", + want: "my-app-powersync-sync-rules", + }, + { + name: "external ref", + configMapRef: "my-custom-rules", + want: "my-custom-rules", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + project := newTestProject("my-app", "default") + project.Spec.Powersync.SyncRules.ConfigMapRef = tt.configMapRef + + got := SyncRulesConfigMapName(project) + if got != tt.want { + t.Errorf("SyncRulesConfigMapName() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestBuildPowersyncConfigMap(t *testing.T) { + project := newTestProject("my-app", "test-ns") + dbHost := "my-app-rw" + + cm := BuildPowersyncConfigMap(project, dbHost) + + if cm.Name != "my-app-powersync-config" { + t.Errorf("Name = %q, want %q", cm.Name, "my-app-powersync-config") + } + if cm.Namespace != "test-ns" { + t.Errorf("Namespace = %q, want %q", cm.Namespace, "test-ns") + } + + configJSON, ok := cm.Data["config.json"] + if !ok { + t.Fatal("config.json key not found") + } + + // Parse the JSON to validate structure + var config powersyncConfig + if err := json.Unmarshal([]byte(configJSON), &config); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + // Storage + if config.Storage.Type != "postgresql" { + t.Errorf("storage type = %q, want postgresql", config.Storage.Type) + } + if !strings.Contains(config.Storage.URI, dbHost) { + t.Errorf("storage URI %q should contain %q", config.Storage.URI, dbHost) + } + + // Replication + if len(config.Replication.Connections) != 1 { + t.Fatalf("expected 1 replication connection, got %d", len(config.Replication.Connections)) + } + conn := config.Replication.Connections[0] + if conn.Type != "postgresql" { + t.Errorf("connection type = %q, want postgresql", conn.Type) + } + if conn.Tag != "default" { + t.Errorf("connection tag = %q, want default", conn.Tag) + } + + // Client auth + if !config.ClientAuth.Supabase { + t.Error("expected supabase auth = true") + } + if config.ClientAuth.SupabaseJWTSecret != "{{ env.PS_JWT_SECRET }}" { + t.Errorf("JWT secret = %q, want env template", config.ClientAuth.SupabaseJWTSecret) + } + + // Sync rules path + if config.SyncRules.Path != "/powersync/sync_rules/sync_rules.yaml" { + t.Errorf("sync rules path = %q", config.SyncRules.Path) + } +} + +func TestBuildPowersyncSyncRulesConfigMap_Default(t *testing.T) { + project := newTestProject("my-app", "test-ns") + + cm := BuildPowersyncSyncRulesConfigMap(project) + + if cm == nil { + t.Fatal("expected non-nil ConfigMap") + } + if cm.Name != "my-app-powersync-sync-rules" { + t.Errorf("Name = %q, want %q", cm.Name, "my-app-powersync-sync-rules") + } + + syncRules, ok := cm.Data["sync_rules.yaml"] + if !ok { + t.Fatal("sync_rules.yaml key not found") + } + if !strings.Contains(syncRules, "bucket_definitions") { + t.Error("default sync rules should contain bucket_definitions") + } +} + +func TestBuildPowersyncSyncRulesConfigMap_Inline(t *testing.T) { + project := newTestProject("my-app", "default") + project.Spec.Powersync.SyncRules.Inline = "bucket_definitions:\n custom:\n data:\n - SELECT * FROM users" + + cm := BuildPowersyncSyncRulesConfigMap(project) + + if cm == nil { + t.Fatal("expected non-nil ConfigMap") + } + if !strings.Contains(cm.Data["sync_rules.yaml"], "custom") { + t.Error("expected inline sync rules to be used") + } +} + +func TestBuildPowersyncSyncRulesConfigMap_ExternalRef(t *testing.T) { + project := newTestProject("my-app", "default") + project.Spec.Powersync.SyncRules.ConfigMapRef = "my-external-rules" + + cm := BuildPowersyncSyncRulesConfigMap(project) + + if cm != nil { + t.Error("expected nil ConfigMap when external ConfigMapRef is set") + } +} diff --git a/internal/resources/deployments/meilisearch_test.go b/internal/resources/deployments/meilisearch_test.go new file mode 100644 index 0000000..461afe6 --- /dev/null +++ b/internal/resources/deployments/meilisearch_test.go @@ -0,0 +1,194 @@ +package deployments + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + + supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" + "github.com/GuionAI/cloudnative-supabase/internal/resources/defaults" +) + +func TestMeilisearchStatefulSetName(t *testing.T) { + project := newTestProject("my-app", "default") + got := MeilisearchStatefulSetName(project) + if got != "my-app-meilisearch" { + t.Errorf("MeilisearchStatefulSetName() = %q, want %q", got, "my-app-meilisearch") + } +} + +func TestBuildMeilisearchStatefulSet(t *testing.T) { + project := newTestProject("my-app", "test-ns") + secretNames := newTestSecretNames() + + sts := BuildMeilisearchStatefulSet(project, secretNames) + + // Metadata + if sts.Name != "my-app-meilisearch" { + t.Errorf("Name = %q, want %q", sts.Name, "my-app-meilisearch") + } + if sts.Namespace != "test-ns" { + t.Errorf("Namespace = %q, want %q", sts.Namespace, "test-ns") + } + + // Default replica = 1 + if *sts.Spec.Replicas != 1 { + t.Errorf("Replicas = %d, want 1", *sts.Spec.Replicas) + } + + // ServiceName + if sts.Spec.ServiceName != "my-app-meilisearch" { + t.Errorf("ServiceName = %q, want %q", sts.Spec.ServiceName, "my-app-meilisearch") + } + + // Container + c := sts.Spec.Template.Spec.Containers[0] + + expectedImage := defaults.MeilisearchImage + ":" + defaults.MeilisearchTag + if c.Image != expectedImage { + t.Errorf("image = %q, want %q", c.Image, expectedImage) + } + + // Port + if len(c.Ports) != 1 || c.Ports[0].ContainerPort != MeilisearchHTTPPort { + t.Errorf("expected port %d", MeilisearchHTTPPort) + } + + // Env vars + envMap := make(map[string]corev1.EnvVar) + for _, e := range c.Env { + envMap[e.Name] = e + } + + if envMap["MEILI_ENV"].Value != "production" { + t.Errorf("MEILI_ENV = %q, want %q", envMap["MEILI_ENV"].Value, "production") + } + if envMap["MEILI_NO_ANALYTICS"].Value != "true" { + t.Errorf("MEILI_NO_ANALYTICS = %q, want %q", envMap["MEILI_NO_ANALYTICS"].Value, "true") + } + if envMap["MEILI_MASTER_KEY"].ValueFrom == nil || envMap["MEILI_MASTER_KEY"].ValueFrom.SecretKeyRef.Key != "masterKey" { + t.Error("MEILI_MASTER_KEY should reference secret masterKey") + } + + // Probes + if c.LivenessProbe == nil || c.LivenessProbe.HTTPGet.Path != "/health" { + t.Error("expected liveness probe on /health") + } + if c.ReadinessProbe == nil || c.ReadinessProbe.HTTPGet.Path != "/health" { + t.Error("expected readiness probe on /health") + } + + // Volume mount + if len(c.VolumeMounts) != 1 || c.VolumeMounts[0].MountPath != "/meili_data" { + t.Error("expected /meili_data volume mount") + } + + // VolumeClaimTemplates + if len(sts.Spec.VolumeClaimTemplates) != 1 { + t.Fatalf("expected 1 VolumeClaimTemplate, got %d", len(sts.Spec.VolumeClaimTemplates)) + } + pvc := sts.Spec.VolumeClaimTemplates[0] + storageReq := pvc.Spec.Resources.Requests[corev1.ResourceStorage] + if storageReq.Cmp(resource.MustParse("10Gi")) != 0 { + t.Errorf("storage = %s, want 10Gi", storageReq.String()) + } + + // Default: no storage class + if pvc.Spec.StorageClassName != nil { + t.Errorf("expected nil StorageClassName, got %q", *pvc.Spec.StorageClassName) + } +} + +func TestBuildMeilisearchStatefulSet_CustomStorage(t *testing.T) { + project := newTestProject("my-app", "default") + project.Spec.Meilisearch.Persistence.Size = "50Gi" + project.Spec.Meilisearch.Persistence.StorageClass = "longhorn" + secretNames := newTestSecretNames() + + sts := BuildMeilisearchStatefulSet(project, secretNames) + + pvc := sts.Spec.VolumeClaimTemplates[0] + storageReq := pvc.Spec.Resources.Requests[corev1.ResourceStorage] + if storageReq.Cmp(resource.MustParse("50Gi")) != 0 { + t.Errorf("storage = %s, want 50Gi", storageReq.String()) + } + if pvc.Spec.StorageClassName == nil || *pvc.Spec.StorageClassName != "longhorn" { + t.Error("expected storageClassName = longhorn") + } +} + +func TestBuildMeilisearchStatefulSet_MasterKeySecretRef(t *testing.T) { + project := newTestProject("my-app", "default") + project.Spec.Meilisearch.MasterKeySecretRef = "my-existing-key" + secretNames := newTestSecretNames() + + sts := BuildMeilisearchStatefulSet(project, secretNames) + c := sts.Spec.Template.Spec.Containers[0] + + for _, e := range c.Env { + if e.Name == "MEILI_MASTER_KEY" { + if e.ValueFrom.SecretKeyRef.Name != "my-existing-key" { + t.Errorf("MEILI_MASTER_KEY secret = %q, want %q", e.ValueFrom.SecretKeyRef.Name, "my-existing-key") + } + return + } + } + t.Error("MEILI_MASTER_KEY env var not found") +} + +func TestDefaultMeilisearchResources(t *testing.T) { + res := DefaultMeilisearchResources() + if res.Requests.Memory().Cmp(resource.MustParse("512Mi")) != 0 { + t.Errorf("memory request = %s, want 512Mi", res.Requests.Memory()) + } + if res.Limits.Memory().Cmp(resource.MustParse("2Gi")) != 0 { + t.Errorf("memory limit = %s, want 2Gi", res.Limits.Memory()) + } +} + +func TestNormalizeMeilisearchResources(t *testing.T) { + // Empty uses defaults + got := normalizeMeilisearchResources(corev1.ResourceRequirements{}) + if got.Requests.Memory().Cmp(resource.MustParse("512Mi")) != 0 { + t.Errorf("expected default 512Mi, got %s", got.Requests.Memory()) + } + + // Custom preserved + custom := corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("1Gi"), + }, + } + got = normalizeMeilisearchResources(custom) + if got.Requests.Memory().Cmp(resource.MustParse("1Gi")) != 0 { + t.Errorf("expected custom 1Gi, got %s", got.Requests.Memory()) + } +} + +func TestBuildMeilisearchStatefulSet_ImagePullSecrets(t *testing.T) { + project := newTestProject("my-app", "default") + project.Spec.ImagePullSecrets = []corev1.LocalObjectReference{ + {Name: "my-registry-secret"}, + } + secretNames := newTestSecretNames() + + sts := BuildMeilisearchStatefulSet(project, secretNames) + if len(sts.Spec.Template.Spec.ImagePullSecrets) != 1 { + t.Fatal("expected 1 image pull secret") + } + if sts.Spec.Template.Spec.ImagePullSecrets[0].Name != "my-registry-secret" { + t.Errorf("imagePullSecret = %q, want %q", sts.Spec.Template.Spec.ImagePullSecrets[0].Name, "my-registry-secret") + } +} + +func TestBuildMeilisearchStatefulSet_CustomReplicas(t *testing.T) { + project := newTestProject("my-app", "default") + project.Spec.Meilisearch = &supabasev1alpha1.MeilisearchSpec{Replicas: 2} + secretNames := newTestSecretNames() + + sts := BuildMeilisearchStatefulSet(project, secretNames) + if *sts.Spec.Replicas != 2 { + t.Errorf("Replicas = %d, want 2", *sts.Spec.Replicas) + } +} diff --git a/internal/resources/deployments/powersync_test.go b/internal/resources/deployments/powersync_test.go new file mode 100644 index 0000000..2ab0681 --- /dev/null +++ b/internal/resources/deployments/powersync_test.go @@ -0,0 +1,235 @@ +package deployments + +import ( + "testing" + + supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" + "github.com/GuionAI/cloudnative-supabase/internal/resources/defaults" +) + +func TestPowersyncNames(t *testing.T) { + project := newTestProject("my-app", "default") + + tests := []struct { + name string + fn func(*supabasev1alpha1.SupabaseProject) string + want string + }{ + {"API", PowersyncAPIDeploymentName, "my-app-powersync-api"}, + {"Replication", PowersyncReplicationDeploymentName, "my-app-powersync-replication"}, + {"Compact", PowersyncCompactCronJobName, "my-app-powersync-compact"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.fn(project) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestBuildPowersyncAPIDeployment(t *testing.T) { + project := newTestProject("my-app", "test-ns") + secretNames := newTestSecretNames() + + dep := BuildPowersyncAPIDeployment(project, secretNames) + + if dep.Name != "my-app-powersync-api" { + t.Errorf("Name = %q, want %q", dep.Name, "my-app-powersync-api") + } + if dep.Namespace != "test-ns" { + t.Errorf("Namespace = %q, want %q", dep.Namespace, "test-ns") + } + + // Default replicas = 1 (NormalizeReplicas(0) = 1) + if *dep.Spec.Replicas != 1 { + t.Errorf("Replicas = %d, want 1", *dep.Spec.Replicas) + } + + c := dep.Spec.Template.Spec.Containers[0] + + // Image + expectedImage := defaults.PowersyncImage + ":" + defaults.PowersyncTag + if c.Image != expectedImage { + t.Errorf("image = %q, want %q", c.Image, expectedImage) + } + + // Command: entry-api.js + if len(c.Command) != 2 || c.Command[1] != "entry-api.js" { + t.Errorf("Command = %v, want [node entry-api.js]", c.Command) + } + + // Ports: HTTP + metrics + if len(c.Ports) != 2 { + t.Fatalf("expected 2 ports, got %d", len(c.Ports)) + } + if c.Ports[0].ContainerPort != PowersyncHTTPPort { + t.Errorf("HTTP port = %d, want %d", c.Ports[0].ContainerPort, PowersyncHTTPPort) + } + if c.Ports[1].ContainerPort != PowersyncMetricsPort { + t.Errorf("metrics port = %d, want %d", c.Ports[1].ContainerPort, PowersyncMetricsPort) + } + + // Probes + if c.LivenessProbe == nil || c.LivenessProbe.HTTPGet.Path != "/api/status" { + t.Error("expected liveness probe on /api/status") + } + if c.ReadinessProbe == nil || c.ReadinessProbe.HTTPGet.Path != "/api/status" { + t.Error("expected readiness probe on /api/status") + } + + // Volume mounts + if len(c.VolumeMounts) != 2 { + t.Fatalf("expected 2 volume mounts, got %d", len(c.VolumeMounts)) + } + + // Volumes + volumes := dep.Spec.Template.Spec.Volumes + if len(volumes) != 2 { + t.Fatalf("expected 2 volumes, got %d", len(volumes)) + } + if volumes[0].Name != "config" { + t.Errorf("volume[0] name = %q, want %q", volumes[0].Name, "config") + } + if volumes[1].Name != "sync-rules" { + t.Errorf("volume[1] name = %q, want %q", volumes[1].Name, "sync-rules") + } +} + +func TestBuildPowersyncAPIDeployment_CustomReplicas(t *testing.T) { + project := newTestProject("my-app", "default") + project.Spec.Powersync.API.Replicas = 3 + secretNames := newTestSecretNames() + + dep := BuildPowersyncAPIDeployment(project, secretNames) + if *dep.Spec.Replicas != 3 { + t.Errorf("Replicas = %d, want 3", *dep.Spec.Replicas) + } +} + +func TestBuildPowersyncAPIDeployment_CustomNodeOptions(t *testing.T) { + project := newTestProject("my-app", "default") + project.Spec.Powersync.API.NodeOptions = "--max-old-space-size=512" + secretNames := newTestSecretNames() + + dep := BuildPowersyncAPIDeployment(project, secretNames) + env := dep.Spec.Template.Spec.Containers[0].Env + + for _, e := range env { + if e.Name == "NODE_OPTIONS" { + if e.Value != "--max-old-space-size=512" { + t.Errorf("NODE_OPTIONS = %q, want --max-old-space-size=512", e.Value) + } + return + } + } + t.Error("NODE_OPTIONS env var not found") +} + +func TestBuildPowersyncReplicationDeployment(t *testing.T) { + project := newTestProject("my-app", "test-ns") + secretNames := newTestSecretNames() + + dep := BuildPowersyncReplicationDeployment(project, secretNames) + + if dep.Name != "my-app-powersync-replication" { + t.Errorf("Name = %q, want %q", dep.Name, "my-app-powersync-replication") + } + + // Replication is always single instance + if *dep.Spec.Replicas != 1 { + t.Errorf("Replicas = %d, want 1 (replication must be single instance)", *dep.Spec.Replicas) + } + + c := dep.Spec.Template.Spec.Containers[0] + + // Command: entry-replication.js + if len(c.Command) != 2 || c.Command[1] != "entry-replication.js" { + t.Errorf("Command = %v, want [node entry-replication.js]", c.Command) + } + + // Only metrics port (no HTTP) + if len(c.Ports) != 1 || c.Ports[0].ContainerPort != PowersyncMetricsPort { + t.Errorf("expected only metrics port %d", PowersyncMetricsPort) + } + + // Default NODE_OPTIONS for replication + for _, e := range c.Env { + if e.Name == "NODE_OPTIONS" { + if e.Value != "--max-old-space-size=482" { + t.Errorf("NODE_OPTIONS = %q, want --max-old-space-size=482", e.Value) + } + return + } + } + t.Error("NODE_OPTIONS env var not found") +} + +func TestBuildPowersyncCompactCronJob(t *testing.T) { + project := newTestProject("my-app", "test-ns") + secretNames := newTestSecretNames() + + cj := BuildPowersyncCompactCronJob(project, secretNames) + + if cj.Name != "my-app-powersync-compact" { + t.Errorf("Name = %q, want %q", cj.Name, "my-app-powersync-compact") + } + if cj.Namespace != "test-ns" { + t.Errorf("Namespace = %q, want %q", cj.Namespace, "test-ns") + } + + // Default schedule + if cj.Spec.Schedule != "0 3 * * *" { + t.Errorf("Schedule = %q, want %q", cj.Spec.Schedule, "0 3 * * *") + } + + // Command: entry-compact.js + c := cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0] + if len(c.Command) != 2 || c.Command[1] != "entry-compact.js" { + t.Errorf("Command = %v, want [node entry-compact.js]", c.Command) + } +} + +func TestBuildPowersyncCompactCronJob_CustomSchedule(t *testing.T) { + project := newTestProject("my-app", "default") + project.Spec.Powersync.Compact.Schedule = "0 2 * * *" + secretNames := newTestSecretNames() + + cj := BuildPowersyncCompactCronJob(project, secretNames) + if cj.Spec.Schedule != "0 2 * * *" { + t.Errorf("Schedule = %q, want %q", cj.Spec.Schedule, "0 2 * * *") + } +} + +func TestBuildPowersyncCompactCronJob_Disabled(t *testing.T) { + project := newTestProject("my-app", "default") + project.Spec.Powersync.Compact.Enabled = false + secretNames := newTestSecretNames() + + cj := BuildPowersyncCompactCronJob(project, secretNames) + if cj != nil { + t.Error("expected nil CronJob when compact is disabled") + } +} + +func TestBuildPowersyncEnvVars(t *testing.T) { + project := newTestProject("my-app", "default") + secretNames := newTestSecretNames() + + dep := BuildPowersyncAPIDeployment(project, secretNames) + env := dep.Spec.Template.Spec.Containers[0].Env + + envMap := make(map[string]struct{}) + for _, e := range env { + envMap[e.Name] = struct{}{} + } + + required := []string{"POWERSYNC_CONFIG_PATH", "NODE_OPTIONS", "PS_PG_PASSWORD", "PS_POWERSYNC_STORAGE_URI", "PS_POWERSYNC_REPLICATION_URI", "PS_JWT_SECRET"} + for _, name := range required { + if _, ok := envMap[name]; !ok { + t.Errorf("missing required env var: %s", name) + } + } +} diff --git a/internal/resources/deployments/redis_test.go b/internal/resources/deployments/redis_test.go new file mode 100644 index 0000000..92d597f --- /dev/null +++ b/internal/resources/deployments/redis_test.go @@ -0,0 +1,157 @@ +package deployments + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +func TestSequinRedisStatefulSetName(t *testing.T) { + project := newTestProject("my-app", "default") + got := SequinRedisStatefulSetName(project) + if got != "my-app-sequin-redis" { + t.Errorf("SequinRedisStatefulSetName() = %q, want %q", got, "my-app-sequin-redis") + } +} + +func TestSequinRedisServiceName(t *testing.T) { + project := newTestProject("my-app", "default") + got := SequinRedisServiceName(project) + if got != "my-app-sequin-redis" { + t.Errorf("SequinRedisServiceName() = %q, want %q", got, "my-app-sequin-redis") + } +} + +func TestBuildSequinRedisStatefulSet(t *testing.T) { + project := newTestProject("my-app", "test-ns") + + sts := BuildSequinRedisStatefulSet(project) + + // Metadata + if sts.Name != "my-app-sequin-redis" { + t.Errorf("Name = %q, want %q", sts.Name, "my-app-sequin-redis") + } + if sts.Namespace != "test-ns" { + t.Errorf("Namespace = %q, want %q", sts.Namespace, "test-ns") + } + + // Always single replica + if *sts.Spec.Replicas != 1 { + t.Errorf("Replicas = %d, want 1", *sts.Spec.Replicas) + } + + // ServiceName matches StatefulSet name + if sts.Spec.ServiceName != "my-app-sequin-redis" { + t.Errorf("ServiceName = %q, want %q", sts.Spec.ServiceName, "my-app-sequin-redis") + } + + // Container + containers := sts.Spec.Template.Spec.Containers + if len(containers) != 1 { + t.Fatalf("expected 1 container, got %d", len(containers)) + } + c := containers[0] + + if c.Name != "redis" { + t.Errorf("container name = %q, want %q", c.Name, "redis") + } + + // AOF persistence command + if len(c.Command) != 3 || c.Command[0] != "redis-server" || c.Command[2] != "yes" { + t.Errorf("Command = %v, want [redis-server --appendonly yes]", c.Command) + } + + // Port + if len(c.Ports) != 1 || c.Ports[0].ContainerPort != RedisPort { + t.Errorf("expected port %d", RedisPort) + } + + // Probes + if c.LivenessProbe == nil || c.LivenessProbe.TCPSocket == nil { + t.Error("expected TCP liveness probe") + } + if c.ReadinessProbe == nil || c.ReadinessProbe.Exec == nil { + t.Error("expected exec readiness probe (redis-cli ping)") + } + + // Volume mount + if len(c.VolumeMounts) != 1 || c.VolumeMounts[0].MountPath != "/data" { + t.Error("expected /data volume mount") + } + + // Security context: non-root + podSec := sts.Spec.Template.Spec.SecurityContext + if podSec == nil || !*podSec.RunAsNonRoot { + t.Error("expected RunAsNonRoot=true") + } + + // Container security: drop ALL capabilities + containerSec := c.SecurityContext + if containerSec == nil || !*containerSec.AllowPrivilegeEscalation { + // AllowPrivilegeEscalation should be false + } + if containerSec == nil || len(containerSec.Capabilities.Drop) == 0 { + t.Error("expected dropped capabilities") + } + + // VolumeClaimTemplates + if len(sts.Spec.VolumeClaimTemplates) != 1 { + t.Fatalf("expected 1 VolumeClaimTemplate, got %d", len(sts.Spec.VolumeClaimTemplates)) + } + pvc := sts.Spec.VolumeClaimTemplates[0] + if pvc.Name != "data" { + t.Errorf("PVC name = %q, want %q", pvc.Name, "data") + } + + // Default storage size + storageReq := pvc.Spec.Resources.Requests[corev1.ResourceStorage] + if storageReq.Cmp(resource.MustParse("2Gi")) != 0 { + t.Errorf("storage = %s, want 2Gi", storageReq.String()) + } +} + +func TestBuildSequinRedisStatefulSet_CustomStorage(t *testing.T) { + project := newTestProject("my-app", "default") + project.Spec.Sequin.Redis.Storage.Size = "5Gi" + project.Spec.Sequin.Redis.Storage.StorageClass = "fast-ssd" + + sts := BuildSequinRedisStatefulSet(project) + + pvc := sts.Spec.VolumeClaimTemplates[0] + storageReq := pvc.Spec.Resources.Requests[corev1.ResourceStorage] + if storageReq.Cmp(resource.MustParse("5Gi")) != 0 { + t.Errorf("storage = %s, want 5Gi", storageReq.String()) + } + if pvc.Spec.StorageClassName == nil || *pvc.Spec.StorageClassName != "fast-ssd" { + t.Error("expected storageClassName = fast-ssd") + } +} + +func TestBuildSequinRedisService(t *testing.T) { + project := newTestProject("my-app", "test-ns") + svc := BuildSequinRedisService(project) + + if svc.Name != "my-app-sequin-redis" { + t.Errorf("Name = %q, want %q", svc.Name, "my-app-sequin-redis") + } + if svc.Namespace != "test-ns" { + t.Errorf("Namespace = %q, want %q", svc.Namespace, "test-ns") + } + if svc.Spec.Type != corev1.ServiceTypeClusterIP { + t.Errorf("Type = %q, want ClusterIP", svc.Spec.Type) + } + if len(svc.Spec.Ports) != 1 || svc.Spec.Ports[0].Port != RedisPort { + t.Errorf("expected port %d", RedisPort) + } +} + +func TestDefaultRedisResources(t *testing.T) { + res := DefaultRedisResources() + if res.Requests.Memory().Cmp(resource.MustParse("128Mi")) != 0 { + t.Errorf("memory request = %s, want 128Mi", res.Requests.Memory()) + } + if res.Limits.Memory().Cmp(resource.MustParse("256Mi")) != 0 { + t.Errorf("memory limit = %s, want 256Mi", res.Limits.Memory()) + } +} diff --git a/internal/resources/deployments/sequin_test.go b/internal/resources/deployments/sequin_test.go new file mode 100644 index 0000000..c4c48d9 --- /dev/null +++ b/internal/resources/deployments/sequin_test.go @@ -0,0 +1,320 @@ +package deployments + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" + "github.com/GuionAI/cloudnative-supabase/internal/resources/defaults" +) + +func newTestProject(name, namespace string) *supabasev1alpha1.SupabaseProject { + return &supabasev1alpha1.SupabaseProject{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: supabasev1alpha1.SupabaseProjectSpec{ + Sequin: &supabasev1alpha1.SequinSpec{}, + Powersync: &supabasev1alpha1.PowersyncSpec{ + Compact: supabasev1alpha1.PowersyncCompactSpec{Enabled: true}, + }, + Meilisearch: &supabasev1alpha1.MeilisearchSpec{}, + }, + } +} + +func newTestSecretNames() *supabasev1alpha1.SecretNamesStatus { + return &supabasev1alpha1.SecretNamesStatus{ + JWT: "test-jwt", + Sequin: "test-sequin", + SequinPassword: "test-sequin-password", + SequinReplicationPassword: "test-sequin-replication-password", + PowersyncStoragePassword: "test-powersync-storage-password", + MeilisearchMasterKey: "test-meilisearch-master-key", + } +} + +func TestSequinDeploymentName(t *testing.T) { + project := newTestProject("my-app", "default") + got := SequinDeploymentName(project) + want := "my-app-sequin" + if got != want { + t.Errorf("SequinDeploymentName() = %q, want %q", got, want) + } +} + +func TestResolveImage(t *testing.T) { + tests := []struct { + name string + spec supabasev1alpha1.ImageSpec + defaultImage string + defaultTag string + want string + }{ + { + name: "all defaults", + spec: supabasev1alpha1.ImageSpec{}, + defaultImage: "sequin/sequin", + defaultTag: "v0.13.25", + want: "sequin/sequin:v0.13.25", + }, + { + name: "custom tag", + spec: supabasev1alpha1.ImageSpec{Tag: "v1.0.0"}, + defaultImage: "sequin/sequin", + defaultTag: "v0.13.25", + want: "sequin/sequin:v1.0.0", + }, + { + name: "custom repository", + spec: supabasev1alpha1.ImageSpec{Repository: "myorg/sequin"}, + defaultImage: "sequin/sequin", + defaultTag: "v0.13.25", + want: "myorg/sequin:v0.13.25", + }, + { + name: "custom registry", + spec: supabasev1alpha1.ImageSpec{Registry: "ghcr.io"}, + defaultImage: "sequin/sequin", + defaultTag: "v0.13.25", + want: "ghcr.io/sequin/sequin:v0.13.25", + }, + { + name: "full override", + spec: supabasev1alpha1.ImageSpec{ + Registry: "ghcr.io", + Repository: "guionai/sequin", + Tag: "flicknote", + }, + defaultImage: "sequin/sequin", + defaultTag: "v0.13.25", + want: "ghcr.io/guionai/sequin:flicknote", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveImage(tt.spec, tt.defaultImage, tt.defaultTag) + if got != tt.want { + t.Errorf("ResolveImage() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestResolvePullPolicy(t *testing.T) { + tests := []struct { + name string + spec supabasev1alpha1.ImageSpec + want corev1.PullPolicy + }{ + { + name: "default", + spec: supabasev1alpha1.ImageSpec{}, + want: corev1.PullIfNotPresent, + }, + { + name: "always", + spec: supabasev1alpha1.ImageSpec{PullPolicy: corev1.PullAlways}, + want: corev1.PullAlways, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolvePullPolicy(tt.spec) + if got != tt.want { + t.Errorf("ResolvePullPolicy() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestBuildSequinDeployment(t *testing.T) { + project := newTestProject("my-app", "test-ns") + secretNames := newTestSecretNames() + + dep := BuildSequinDeployment(project, secretNames) + + // Metadata + if dep.Name != "my-app-sequin" { + t.Errorf("Name = %q, want %q", dep.Name, "my-app-sequin") + } + if dep.Namespace != "test-ns" { + t.Errorf("Namespace = %q, want %q", dep.Namespace, "test-ns") + } + + // Replicas default to 1 + if *dep.Spec.Replicas != 1 { + t.Errorf("Replicas = %d, want 1", *dep.Spec.Replicas) + } + + // Container + containers := dep.Spec.Template.Spec.Containers + if len(containers) != 1 { + t.Fatalf("expected 1 container, got %d", len(containers)) + } + c := containers[0] + + if c.Name != SequinComponentName { + t.Errorf("container name = %q, want %q", c.Name, SequinComponentName) + } + + expectedImage := defaults.SequinImage + ":" + defaults.SequinTag + if c.Image != expectedImage { + t.Errorf("image = %q, want %q", c.Image, expectedImage) + } + + // Ports + if len(c.Ports) != 2 { + t.Fatalf("expected 2 ports, got %d", len(c.Ports)) + } + if c.Ports[0].ContainerPort != SequinHTTPPort { + t.Errorf("HTTP port = %d, want %d", c.Ports[0].ContainerPort, SequinHTTPPort) + } + if c.Ports[1].ContainerPort != SequinMetricsPort { + t.Errorf("metrics port = %d, want %d", c.Ports[1].ContainerPort, SequinMetricsPort) + } + + // Probes + if c.LivenessProbe == nil { + t.Error("expected liveness probe") + } + if c.ReadinessProbe == nil { + t.Error("expected readiness probe") + } + + // Default resources applied + if c.Resources.Requests.Memory().Cmp(resource.MustParse("256Mi")) != 0 { + t.Errorf("memory request = %s, want 256Mi", c.Resources.Requests.Memory()) + } +} + +func TestBuildSequinDeployment_CustomReplicas(t *testing.T) { + project := newTestProject("my-app", "default") + project.Spec.Sequin.Replicas = 3 + secretNames := newTestSecretNames() + + dep := BuildSequinDeployment(project, secretNames) + if *dep.Spec.Replicas != 3 { + t.Errorf("Replicas = %d, want 3", *dep.Spec.Replicas) + } +} + +func TestBuildSequinDeployment_ExternalRedis(t *testing.T) { + project := newTestProject("my-app", "default") + project.Spec.Sequin.Redis.External = &supabasev1alpha1.ExternalRedisSpec{ + Host: "redis.infra.svc", + Port: 6380, + } + secretNames := newTestSecretNames() + + dep := BuildSequinDeployment(project, secretNames) + env := dep.Spec.Template.Spec.Containers[0].Env + + var redisURL string + for _, e := range env { + if e.Name == "REDIS_URL" { + redisURL = e.Value + } + } + + want := "redis://redis.infra.svc:6380" + if redisURL != want { + t.Errorf("REDIS_URL = %q, want %q", redisURL, want) + } +} + +func TestBuildSequinDeployment_BundledRedis(t *testing.T) { + project := newTestProject("my-app", "default") + // External is nil by default - should use bundled Redis URL + secretNames := newTestSecretNames() + + dep := BuildSequinDeployment(project, secretNames) + env := dep.Spec.Template.Spec.Containers[0].Env + + var redisURL string + for _, e := range env { + if e.Name == "REDIS_URL" { + redisURL = e.Value + } + } + + want := "redis://my-app-sequin-redis:6379" + if redisURL != want { + t.Errorf("REDIS_URL = %q, want %q", redisURL, want) + } +} + +func TestBuildSequinDeployment_EnvVars(t *testing.T) { + project := newTestProject("my-app", "default") + secretNames := newTestSecretNames() + + dep := BuildSequinDeployment(project, secretNames) + env := dep.Spec.Template.Spec.Containers[0].Env + + envMap := make(map[string]corev1.EnvVar) + for _, e := range env { + envMap[e.Name] = e + } + + // Check required env vars exist + requiredEnvs := []string{"PG_HOSTNAME", "PG_PORT", "PG_DATABASE", "PG_USERNAME", "PG_PASSWORD", "REDIS_URL", "SEQUIN_ENV", "SECRET_KEY_BASE", "VAULT_KEY"} + for _, name := range requiredEnvs { + if _, ok := envMap[name]; !ok { + t.Errorf("missing required env var: %s", name) + } + } + + // Check PG_DATABASE is "sequin" + if envMap["PG_DATABASE"].Value != "sequin" { + t.Errorf("PG_DATABASE = %q, want %q", envMap["PG_DATABASE"].Value, "sequin") + } + + // Check secret refs + if envMap["PG_USERNAME"].ValueFrom.SecretKeyRef.Name != secretNames.SequinPassword { + t.Errorf("PG_USERNAME secret ref = %q, want %q", envMap["PG_USERNAME"].ValueFrom.SecretKeyRef.Name, secretNames.SequinPassword) + } + if envMap["SECRET_KEY_BASE"].ValueFrom.SecretKeyRef.Name != secretNames.Sequin { + t.Errorf("SECRET_KEY_BASE secret ref = %q, want %q", envMap["SECRET_KEY_BASE"].ValueFrom.SecretKeyRef.Name, secretNames.Sequin) + } +} + +func TestNormalizeSequinResources(t *testing.T) { + tests := []struct { + name string + resources corev1.ResourceRequirements + isDefault bool + }{ + { + name: "empty uses defaults", + resources: corev1.ResourceRequirements{}, + isDefault: true, + }, + { + name: "custom preserved", + resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("1Gi"), + }, + }, + isDefault: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NormalizeSequinResources(tt.resources) + defaultRes := DefaultSequinResources() + isDefault := got.Requests.Memory().Cmp(*defaultRes.Requests.Memory()) == 0 + + if tt.isDefault != isDefault { + t.Errorf("isDefault = %v, want %v", isDefault, tt.isDefault) + } + }) + } +} diff --git a/internal/resources/secrets/secrets_test.go b/internal/resources/secrets/secrets_test.go new file mode 100644 index 0000000..6daad42 --- /dev/null +++ b/internal/resources/secrets/secrets_test.go @@ -0,0 +1,214 @@ +package secrets + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" +) + +func newTestProject(name, namespace string) *supabasev1alpha1.SupabaseProject { + return &supabasev1alpha1.SupabaseProject{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: supabasev1alpha1.SupabaseProjectSpec{ + Sequin: &supabasev1alpha1.SequinSpec{}, + Powersync: &supabasev1alpha1.PowersyncSpec{}, + Meilisearch: &supabasev1alpha1.MeilisearchSpec{}, + }, + } +} + +func TestSequinSecretNames(t *testing.T) { + project := newTestProject("my-app", "default") + + sequin, sequinPw, sequinRepl := SequinSecretNames(project) + + if sequin != "my-app-sequin" { + t.Errorf("sequin = %q, want %q", sequin, "my-app-sequin") + } + if sequinPw != "my-app-sequin-password" { + t.Errorf("sequinPassword = %q, want %q", sequinPw, "my-app-sequin-password") + } + if sequinRepl != "my-app-sequin-replication-password" { + t.Errorf("sequinReplication = %q, want %q", sequinRepl, "my-app-sequin-replication-password") + } +} + +func TestPowersyncSecretNames(t *testing.T) { + project := newTestProject("my-app", "default") + got := PowersyncSecretNames(project) + if got != "my-app-powersync-storage-password" { + t.Errorf("got %q, want %q", got, "my-app-powersync-storage-password") + } +} + +func TestMeilisearchSecretName(t *testing.T) { + tests := []struct { + name string + masterKeyRef string + wantSecretName string + }{ + { + name: "auto-generated", + masterKeyRef: "", + wantSecretName: "my-app-meilisearch-master-key", + }, + { + name: "user-provided", + masterKeyRef: "my-existing-key", + wantSecretName: "my-existing-key", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + project := newTestProject("my-app", "default") + project.Spec.Meilisearch.MasterKeySecretRef = tt.masterKeyRef + + got := MeilisearchSecretName(project) + if got != tt.wantSecretName { + t.Errorf("MeilisearchSecretName() = %q, want %q", got, tt.wantSecretName) + } + }) + } +} + +func TestGenerateSequinSecrets(t *testing.T) { + project := newTestProject("my-app", "test-ns") + + secrets, err := GenerateSequinSecrets(project) + if err != nil { + t.Fatalf("GenerateSequinSecrets() error = %v", err) + } + + if len(secrets) != 3 { + t.Fatalf("expected 3 secrets, got %d", len(secrets)) + } + + // Sequin app secret + appSecret := secrets[0] + if appSecret.Name != "my-app-sequin" { + t.Errorf("app secret name = %q, want %q", appSecret.Name, "my-app-sequin") + } + if appSecret.Namespace != "test-ns" { + t.Errorf("app secret namespace = %q, want %q", appSecret.Namespace, "test-ns") + } + + requiredAppKeys := []string{"secretKeyBase", "vaultKey", "apiToken"} + for _, key := range requiredAppKeys { + if _, ok := appSecret.StringData[key]; !ok { + t.Errorf("app secret missing key: %s", key) + } + } + + // secretKeyBase should be 128 chars (64 bytes hex) + if len(appSecret.StringData["secretKeyBase"]) != 128 { + t.Errorf("secretKeyBase length = %d, want 128", len(appSecret.StringData["secretKeyBase"])) + } + + // Sequin password secret + pwSecret := secrets[1] + if pwSecret.Name != "my-app-sequin-password" { + t.Errorf("password secret name = %q, want %q", pwSecret.Name, "my-app-sequin-password") + } + if pwSecret.StringData["username"] != "sequin" { + t.Errorf("username = %q, want %q", pwSecret.StringData["username"], "sequin") + } + + // Replication password secret + replSecret := secrets[2] + if replSecret.Name != "my-app-sequin-replication-password" { + t.Errorf("replication secret name = %q, want %q", replSecret.Name, "my-app-sequin-replication-password") + } + if replSecret.StringData["username"] != "sequin_replication" { + t.Errorf("username = %q, want %q", replSecret.StringData["username"], "sequin_replication") + } +} + +func TestGeneratePowersyncSecrets(t *testing.T) { + project := newTestProject("my-app", "test-ns") + + secrets, err := GeneratePowersyncSecrets(project) + if err != nil { + t.Fatalf("GeneratePowersyncSecrets() error = %v", err) + } + + if len(secrets) != 1 { + t.Fatalf("expected 1 secret, got %d", len(secrets)) + } + + secret := secrets[0] + if secret.Name != "my-app-powersync-storage-password" { + t.Errorf("name = %q, want %q", secret.Name, "my-app-powersync-storage-password") + } + if secret.StringData["username"] != "powersync_storage" { + t.Errorf("username = %q, want %q", secret.StringData["username"], "powersync_storage") + } + if len(secret.StringData["password"]) == 0 { + t.Error("expected non-empty password") + } +} + +func TestGenerateMeilisearchSecrets(t *testing.T) { + project := newTestProject("my-app", "test-ns") + + secrets, err := GenerateMeilisearchSecrets(project) + if err != nil { + t.Fatalf("GenerateMeilisearchSecrets() error = %v", err) + } + + if len(secrets) != 1 { + t.Fatalf("expected 1 secret, got %d", len(secrets)) + } + + secret := secrets[0] + if secret.Name != "my-app-meilisearch-master-key" { + t.Errorf("name = %q, want %q", secret.Name, "my-app-meilisearch-master-key") + } + if secret.Namespace != "test-ns" { + t.Errorf("namespace = %q, want %q", secret.Namespace, "test-ns") + } + + // masterKey should be 64 chars (32 bytes hex) + masterKey := secret.StringData["masterKey"] + if len(masterKey) != 64 { + t.Errorf("masterKey length = %d, want 64", len(masterKey)) + } +} + +func TestGenerateMeilisearchSecrets_ExistingRef(t *testing.T) { + project := newTestProject("my-app", "default") + project.Spec.Meilisearch.MasterKeySecretRef = "my-existing-key" + + secrets, err := GenerateMeilisearchSecrets(project) + if err != nil { + t.Fatalf("GenerateMeilisearchSecrets() error = %v", err) + } + + if secrets != nil { + t.Errorf("expected nil secrets when MasterKeySecretRef is provided, got %d", len(secrets)) + } +} + +func TestGenerateSecrets_Uniqueness(t *testing.T) { + project := newTestProject("my-app", "default") + + secrets1, err := GenerateSequinSecrets(project) + if err != nil { + t.Fatalf("first call error = %v", err) + } + + secrets2, err := GenerateSequinSecrets(project) + if err != nil { + t.Fatalf("second call error = %v", err) + } + + // Secrets should be different between calls (random generation) + if secrets1[0].StringData["secretKeyBase"] == secrets2[0].StringData["secretKeyBase"] { + t.Error("expected different secretKeyBase values between calls") + } +} diff --git a/internal/resources/services/services_test.go b/internal/resources/services/services_test.go new file mode 100644 index 0000000..dc86edc --- /dev/null +++ b/internal/resources/services/services_test.go @@ -0,0 +1,134 @@ +package services + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" +) + +func newTestProject(name, namespace string) *supabasev1alpha1.SupabaseProject { + return &supabasev1alpha1.SupabaseProject{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + } +} + +func TestBuildSequinService(t *testing.T) { + project := newTestProject("my-app", "test-ns") + svc := BuildSequinService(project) + + if svc.Name != "my-app-sequin" { + t.Errorf("Name = %q, want %q", svc.Name, "my-app-sequin") + } + if svc.Namespace != "test-ns" { + t.Errorf("Namespace = %q, want %q", svc.Namespace, "test-ns") + } + if svc.Spec.Type != corev1.ServiceTypeClusterIP { + t.Errorf("Type = %q, want ClusterIP", svc.Spec.Type) + } + + // Should have HTTP (7376) and metrics (4000) ports + if len(svc.Spec.Ports) != 2 { + t.Fatalf("expected 2 ports, got %d", len(svc.Spec.Ports)) + } + + portMap := make(map[string]int32) + for _, p := range svc.Spec.Ports { + portMap[p.Name] = p.Port + } + + if portMap["http"] != 7376 { + t.Errorf("http port = %d, want 7376", portMap["http"]) + } + if portMap["metrics"] != 4000 { + t.Errorf("metrics port = %d, want 4000", portMap["metrics"]) + } +} + +func TestBuildPowersyncAPIService(t *testing.T) { + project := newTestProject("my-app", "test-ns") + svc := BuildPowersyncAPIService(project) + + if svc.Name != "my-app-powersync-api" { + t.Errorf("Name = %q, want %q", svc.Name, "my-app-powersync-api") + } + if svc.Namespace != "test-ns" { + t.Errorf("Namespace = %q, want %q", svc.Namespace, "test-ns") + } + if svc.Spec.Type != corev1.ServiceTypeClusterIP { + t.Errorf("Type = %q, want ClusterIP", svc.Spec.Type) + } + + // Should have HTTP (8080) and metrics (9464) ports + if len(svc.Spec.Ports) != 2 { + t.Fatalf("expected 2 ports, got %d", len(svc.Spec.Ports)) + } + + portMap := make(map[string]int32) + for _, p := range svc.Spec.Ports { + portMap[p.Name] = p.Port + } + + if portMap["http"] != 8080 { + t.Errorf("http port = %d, want 8080", portMap["http"]) + } + if portMap["metrics"] != 9464 { + t.Errorf("metrics port = %d, want 9464", portMap["metrics"]) + } +} + +func TestBuildMeilisearchService(t *testing.T) { + project := newTestProject("my-app", "test-ns") + svc := BuildMeilisearchService(project) + + if svc.Name != "my-app-meilisearch" { + t.Errorf("Name = %q, want %q", svc.Name, "my-app-meilisearch") + } + if svc.Namespace != "test-ns" { + t.Errorf("Namespace = %q, want %q", svc.Namespace, "test-ns") + } + if svc.Spec.Type != corev1.ServiceTypeClusterIP { + t.Errorf("Type = %q, want ClusterIP", svc.Spec.Type) + } + + // Single HTTP port (7700) + if len(svc.Spec.Ports) != 1 { + t.Fatalf("expected 1 port, got %d", len(svc.Spec.Ports)) + } + if svc.Spec.Ports[0].Port != 7700 { + t.Errorf("port = %d, want 7700", svc.Spec.Ports[0].Port) + } +} + +func TestServiceLabels(t *testing.T) { + project := newTestProject("my-app", "default") + + tests := []struct { + name string + svc *corev1.Service + component string + }{ + {"Sequin", BuildSequinService(project), "sequin"}, + {"Powersync", BuildPowersyncAPIService(project), "powersync-api"}, + {"Meilisearch", BuildMeilisearchService(project), "meilisearch"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + labels := tt.svc.Labels + if labels["app.kubernetes.io/component"] != tt.component { + t.Errorf("component label = %q, want %q", labels["app.kubernetes.io/component"], tt.component) + } + + selector := tt.svc.Spec.Selector + if selector["app.kubernetes.io/component"] != tt.component { + t.Errorf("selector component = %q, want %q", selector["app.kubernetes.io/component"], tt.component) + } + }) + } +} From 884e080c50e9d32c058c292d2cf8b0876fb333e7 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 7 Feb 2026 21:53:59 +0800 Subject: [PATCH 10/21] ci(forgejo): add PR/CI workflows with Dagger build pipeline PR workflow: lint (golangci-lint), test (pkg + resources), manifest check CI workflow: lint, test, then build+push via Dagger to in-cluster registry Dagger module builds Go binary in golang:1.25, packages into distroless container, and publishes to registry-docker-registry.arc-systems.svc:5000. Uses in-cluster Dagger engine for builds. Co-Authored-By: Claude Opus 4.6 --- .forgejo/workflows/ci.yaml | 78 +++++++++++++++++++++++++++++++++ .forgejo/workflows/pr.yaml | 62 +++++++++++++++++++++++++++ dagger/dagger.json | 7 +++ dagger/package.json | 12 ++++++ dagger/src/index.ts | 88 ++++++++++++++++++++++++++++++++++++++ dagger/tsconfig.json | 13 ++++++ 6 files changed, 260 insertions(+) create mode 100644 .forgejo/workflows/ci.yaml create mode 100644 .forgejo/workflows/pr.yaml create mode 100644 dagger/dagger.json create mode 100644 dagger/package.json create mode 100644 dagger/src/index.ts create mode 100644 dagger/tsconfig.json diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml new file mode 100644 index 0000000..7035772 --- /dev/null +++ b/.forgejo/workflows/ci.yaml @@ -0,0 +1,78 @@ +name: CI + +on: + push: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: https://github.com/actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Download dependencies + run: go mod download + + - name: Unit tests + run: go test ./pkg/... ./internal/resources/... -v -count=1 + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: https://github.com/actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - uses: https://github.com/golangci/golangci-lint-action@v9 + with: + version: v2.6 + args: --timeout=5m + + build-and-push: + runs-on: ubuntu-latest + needs: [test, lint] + steps: + - uses: actions/checkout@v4 + + - uses: https://github.com/actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Get short SHA + id: sha + run: echo "short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Publish container (SHA tag) + uses: https://github.com/dagger/dagger-for-github@v8.2.0 + env: + _EXPERIMENTAL_DAGGER_RUNNER_HOST: tcp://dagger-engine-0.dagger-headless.arc-systems.svc:8080 + REGISTRY: registry-docker-registry.arc-systems.svc:5000 + IMAGE_NAME: cloudnative-supabase + IMAGE_TAG: sha-${{ steps.sha.outputs.short }} + with: + version: "latest" + verb: call + workdir: dagger + args: publish --source=.. --registry=$REGISTRY --image=$IMAGE_NAME --tag=$IMAGE_TAG + + - name: Publish container (latest tag) + uses: https://github.com/dagger/dagger-for-github@v8.2.0 + env: + _EXPERIMENTAL_DAGGER_RUNNER_HOST: tcp://dagger-engine-0.dagger-headless.arc-systems.svc:8080 + REGISTRY: registry-docker-registry.arc-systems.svc:5000 + IMAGE_NAME: cloudnative-supabase + with: + version: "latest" + verb: call + workdir: dagger + args: publish --source=.. --registry=$REGISTRY --image=$IMAGE_NAME --tag=latest diff --git a/.forgejo/workflows/pr.yaml b/.forgejo/workflows/pr.yaml new file mode 100644 index 0000000..6d43775 --- /dev/null +++ b/.forgejo/workflows/pr.yaml @@ -0,0 +1,62 @@ +name: PR + +on: + pull_request: + branches: + - main + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: https://github.com/actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - uses: https://github.com/golangci/golangci-lint-action@v9 + with: + version: v2.6 + args: --timeout=5m + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: https://github.com/actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Download dependencies + run: go mod download + + - name: Unit tests + run: go test ./pkg/... ./internal/resources/... -v -count=1 + + - name: Build + run: go build -o bin/manager cmd/main.go + + manifests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: https://github.com/actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Generate manifests + run: make generate manifests + + - name: Check for uncommitted changes + run: | + if [ -n "$(git status --porcelain)" ]; then + echo "Generated files are out of date. Run 'make generate manifests' and commit." + git status --porcelain + exit 1 + fi diff --git a/dagger/dagger.json b/dagger/dagger.json new file mode 100644 index 0000000..1836c6b --- /dev/null +++ b/dagger/dagger.json @@ -0,0 +1,7 @@ +{ + "name": "cloudnative-supabase", + "engineVersion": "v0.19.8", + "sdk": { + "source": "typescript" + } +} diff --git a/dagger/package.json b/dagger/package.json new file mode 100644 index 0000000..0f7c54f --- /dev/null +++ b/dagger/package.json @@ -0,0 +1,12 @@ +{ + "name": "cloudnative-supabase-dagger", + "private": true, + "type": "module", + "scripts": { + "develop": "dagger develop" + }, + "dependencies": { + "@dagger.io/dagger": "^0.19.8", + "typescript": "^5.8.0" + } +} diff --git a/dagger/src/index.ts b/dagger/src/index.ts new file mode 100644 index 0000000..916cdfa --- /dev/null +++ b/dagger/src/index.ts @@ -0,0 +1,88 @@ +/** + * CloudNative Supabase - Dagger module for building operator container image + * + * Builds the Go binary and packages it into a distroless container, + * matching the existing Dockerfile pattern. + */ +import { dag, Container, Directory, object, func } from "@dagger.io/dagger"; + +const GO_VERSION = "1.25"; +const DISTROLESS_IMAGE = "gcr.io/distroless/static:nonroot"; + +@object() +export class CloudnativeSupabase { + /** + * Build the operator binary and package into a distroless container + * + * @param source - Root directory of the Go project + */ + @func() + build(source: Directory): Container { + // Build stage: compile Go binary with caching + const builder = dag + .container() + .from(`golang:${GO_VERSION}`) + .withMountedDirectory("/workspace", source) + .withWorkdir("/workspace") + .withMountedCache("/go/pkg/mod", dag.cacheVolume("go-mod")) + .withMountedCache("/root/.cache/go-build", dag.cacheVolume("go-build")) + .withEnvVariable("CGO_ENABLED", "0") + .withEnvVariable("GOOS", "linux") + .withEnvVariable("GOARCH", "amd64") + .withExec(["go", "mod", "download"]) + .withExec(["go", "build", "-a", "-o", "manager", "cmd/main.go"]); + + // Runtime stage: distroless non-root + return dag + .container() + .from(DISTROLESS_IMAGE) + .withFile("/manager", builder.file("/workspace/manager")) + .withEntrypoint(["/manager"]) + .withUser("65532:65532"); + } + + /** + * Build and publish to a registry + * + * @param source - Root directory of the Go project + * @param registry - Registry URL (default: ttl.sh for testing) + * @param image - Image name + * @param tag - Image tag + */ + @func() + async publish( + source: Directory, + registry: string = "ttl.sh", + image: string = "cloudnative-supabase", + tag: string = "latest" + ): Promise { + const container = this.build(source); + const ref = `${registry}/${image}:${tag}`; + return container.publish(ref); + } + + /** + * Run unit tests + * + * @param source - Root directory of the Go project + */ + @func() + async test(source: Directory): Promise { + return dag + .container() + .from(`golang:${GO_VERSION}`) + .withMountedDirectory("/workspace", source) + .withWorkdir("/workspace") + .withMountedCache("/go/pkg/mod", dag.cacheVolume("go-mod")) + .withMountedCache("/root/.cache/go-build", dag.cacheVolume("go-build")) + .withExec([ + "go", + "test", + "./pkg/...", + "./internal/resources/...", + "-v", + "-count=1", + ]) + .stdout(); + } +} diff --git a/dagger/tsconfig.json b/dagger/tsconfig.json new file mode 100644 index 0000000..c488ba4 --- /dev/null +++ b/dagger/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "moduleResolution": "Node", + "experimentalDecorators": true, + "module": "ES2022", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} From 96e11458f1ad952ac7953cee60e4ca822ef14fe3 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 7 Feb 2026 22:14:09 +0800 Subject: [PATCH 11/21] fix(lint): resolve all golangci-lint issues in resource builders and tests Fix gofmt formatting, extract test constants to eliminate goconst/unparam warnings, fix staticcheck SA5011 nil-dereference patterns, rename shadowed import parameter, and lowercase error strings per ST1005. Co-Authored-By: Claude Opus 4.6 --- .../controller/supabaseproject_controller.go | 2 +- internal/resources/configmaps/powersync.go | 6 +-- .../resources/configmaps/powersync_test.go | 31 +++++++----- internal/resources/deployments/meilisearch.go | 2 +- .../resources/deployments/meilisearch_test.go | 28 +++++------ internal/resources/deployments/powersync.go | 10 ++-- .../resources/deployments/powersync_test.go | 26 +++++----- internal/resources/deployments/redis.go | 2 +- internal/resources/deployments/redis_test.go | 50 ++++++++++--------- internal/resources/deployments/sequin_test.go | 31 +++++++----- internal/resources/secrets/secrets_test.go | 33 ++++++------ internal/resources/services/services_test.go | 29 ++++++----- 12 files changed, 136 insertions(+), 114 deletions(-) diff --git a/internal/controller/supabaseproject_controller.go b/internal/controller/supabaseproject_controller.go index e3f07a1..e4b5494 100644 --- a/internal/controller/supabaseproject_controller.go +++ b/internal/controller/supabaseproject_controller.go @@ -1429,7 +1429,7 @@ func (r *SupabaseProjectReconciler) createOrCheckJob(ctx context.Context, projec return nil } if existing.Status.Failed > 0 && existing.Status.Active == 0 { - return fmt.Errorf("Job %s has failed", job.Name) + return fmt.Errorf("job %s has failed", job.Name) } // Job still running diff --git a/internal/resources/configmaps/powersync.go b/internal/resources/configmaps/powersync.go index 6da0e42..1bf40ab 100644 --- a/internal/resources/configmaps/powersync.go +++ b/internal/resources/configmaps/powersync.go @@ -66,9 +66,9 @@ type powersyncConnection struct { } type powersyncClientAuth struct { - Supabase bool `json:"supabase"` - SupabaseJWTSecret string `json:"supabase_jwt_secret"` - Audience []string `json:"audience"` + Supabase bool `json:"supabase"` + SupabaseJWTSecret string `json:"supabase_jwt_secret"` + Audience []string `json:"audience"` } type powersyncSyncRules struct { diff --git a/internal/resources/configmaps/powersync_test.go b/internal/resources/configmaps/powersync_test.go index af06158..d467eba 100644 --- a/internal/resources/configmaps/powersync_test.go +++ b/internal/resources/configmaps/powersync_test.go @@ -10,10 +10,15 @@ import ( supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" ) -func newTestProject(name, namespace string) *supabasev1alpha1.SupabaseProject { +const ( + testProjectName = "my-app" + testNamespace = "test-ns" +) + +func newTestProject(namespace string) *supabasev1alpha1.SupabaseProject { return &supabasev1alpha1.SupabaseProject{ ObjectMeta: metav1.ObjectMeta{ - Name: name, + Name: testProjectName, Namespace: namespace, }, Spec: supabasev1alpha1.SupabaseProjectSpec{ @@ -23,7 +28,7 @@ func newTestProject(name, namespace string) *supabasev1alpha1.SupabaseProject { } func TestPowersyncConfigMapName(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") got := PowersyncConfigMapName(project) if got != "my-app-powersync-config" { t.Errorf("PowersyncConfigMapName() = %q, want %q", got, "my-app-powersync-config") @@ -31,7 +36,7 @@ func TestPowersyncConfigMapName(t *testing.T) { } func TestPowersyncSyncRulesConfigMapName(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") got := PowersyncSyncRulesConfigMapName(project) if got != "my-app-powersync-sync-rules" { t.Errorf("PowersyncSyncRulesConfigMapName() = %q, want %q", got, "my-app-powersync-sync-rules") @@ -58,7 +63,7 @@ func TestSyncRulesConfigMapName(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") project.Spec.Powersync.SyncRules.ConfigMapRef = tt.configMapRef got := SyncRulesConfigMapName(project) @@ -70,7 +75,7 @@ func TestSyncRulesConfigMapName(t *testing.T) { } func TestBuildPowersyncConfigMap(t *testing.T) { - project := newTestProject("my-app", "test-ns") + project := newTestProject(testNamespace) dbHost := "my-app-rw" cm := BuildPowersyncConfigMap(project, dbHost) @@ -78,8 +83,8 @@ func TestBuildPowersyncConfigMap(t *testing.T) { if cm.Name != "my-app-powersync-config" { t.Errorf("Name = %q, want %q", cm.Name, "my-app-powersync-config") } - if cm.Namespace != "test-ns" { - t.Errorf("Namespace = %q, want %q", cm.Namespace, "test-ns") + if cm.Namespace != testNamespace { + t.Errorf("Namespace = %q, want %q", cm.Namespace, testNamespace) } configJSON, ok := cm.Data["config.json"] @@ -128,12 +133,12 @@ func TestBuildPowersyncConfigMap(t *testing.T) { } func TestBuildPowersyncSyncRulesConfigMap_Default(t *testing.T) { - project := newTestProject("my-app", "test-ns") + project := newTestProject(testNamespace) cm := BuildPowersyncSyncRulesConfigMap(project) - if cm == nil { t.Fatal("expected non-nil ConfigMap") + return } if cm.Name != "my-app-powersync-sync-rules" { t.Errorf("Name = %q, want %q", cm.Name, "my-app-powersync-sync-rules") @@ -149,13 +154,13 @@ func TestBuildPowersyncSyncRulesConfigMap_Default(t *testing.T) { } func TestBuildPowersyncSyncRulesConfigMap_Inline(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") project.Spec.Powersync.SyncRules.Inline = "bucket_definitions:\n custom:\n data:\n - SELECT * FROM users" cm := BuildPowersyncSyncRulesConfigMap(project) - if cm == nil { t.Fatal("expected non-nil ConfigMap") + return } if !strings.Contains(cm.Data["sync_rules.yaml"], "custom") { t.Error("expected inline sync rules to be used") @@ -163,7 +168,7 @@ func TestBuildPowersyncSyncRulesConfigMap_Inline(t *testing.T) { } func TestBuildPowersyncSyncRulesConfigMap_ExternalRef(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") project.Spec.Powersync.SyncRules.ConfigMapRef = "my-external-rules" cm := BuildPowersyncSyncRulesConfigMap(project) diff --git a/internal/resources/deployments/meilisearch.go b/internal/resources/deployments/meilisearch.go index 4ebc3a0..b6d1c97 100644 --- a/internal/resources/deployments/meilisearch.go +++ b/internal/resources/deployments/meilisearch.go @@ -29,7 +29,7 @@ import ( ) const ( - MeilisearchComponentName = "meilisearch" + MeilisearchComponentName = "meilisearch" MeilisearchHTTPPort int32 = 7700 ) diff --git a/internal/resources/deployments/meilisearch_test.go b/internal/resources/deployments/meilisearch_test.go index 461afe6..9732107 100644 --- a/internal/resources/deployments/meilisearch_test.go +++ b/internal/resources/deployments/meilisearch_test.go @@ -11,25 +11,25 @@ import ( ) func TestMeilisearchStatefulSetName(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") got := MeilisearchStatefulSetName(project) - if got != "my-app-meilisearch" { - t.Errorf("MeilisearchStatefulSetName() = %q, want %q", got, "my-app-meilisearch") + if got != testProjectName+"-meilisearch" { + t.Errorf("MeilisearchStatefulSetName() = %q, want %q", got, testProjectName+"-meilisearch") } } func TestBuildMeilisearchStatefulSet(t *testing.T) { - project := newTestProject("my-app", "test-ns") + project := newTestProject(testNamespace) secretNames := newTestSecretNames() sts := BuildMeilisearchStatefulSet(project, secretNames) // Metadata - if sts.Name != "my-app-meilisearch" { - t.Errorf("Name = %q, want %q", sts.Name, "my-app-meilisearch") + if sts.Name != testProjectName+"-meilisearch" { + t.Errorf("Name = %q, want %q", sts.Name, testProjectName+"-meilisearch") } - if sts.Namespace != "test-ns" { - t.Errorf("Namespace = %q, want %q", sts.Namespace, "test-ns") + if sts.Namespace != testNamespace { + t.Errorf("Namespace = %q, want %q", sts.Namespace, testNamespace) } // Default replica = 1 @@ -38,8 +38,8 @@ func TestBuildMeilisearchStatefulSet(t *testing.T) { } // ServiceName - if sts.Spec.ServiceName != "my-app-meilisearch" { - t.Errorf("ServiceName = %q, want %q", sts.Spec.ServiceName, "my-app-meilisearch") + if sts.Spec.ServiceName != testProjectName+"-meilisearch" { + t.Errorf("ServiceName = %q, want %q", sts.Spec.ServiceName, testProjectName+"-meilisearch") } // Container @@ -101,7 +101,7 @@ func TestBuildMeilisearchStatefulSet(t *testing.T) { } func TestBuildMeilisearchStatefulSet_CustomStorage(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") project.Spec.Meilisearch.Persistence.Size = "50Gi" project.Spec.Meilisearch.Persistence.StorageClass = "longhorn" secretNames := newTestSecretNames() @@ -119,7 +119,7 @@ func TestBuildMeilisearchStatefulSet_CustomStorage(t *testing.T) { } func TestBuildMeilisearchStatefulSet_MasterKeySecretRef(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") project.Spec.Meilisearch.MasterKeySecretRef = "my-existing-key" secretNames := newTestSecretNames() @@ -167,7 +167,7 @@ func TestNormalizeMeilisearchResources(t *testing.T) { } func TestBuildMeilisearchStatefulSet_ImagePullSecrets(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") project.Spec.ImagePullSecrets = []corev1.LocalObjectReference{ {Name: "my-registry-secret"}, } @@ -183,7 +183,7 @@ func TestBuildMeilisearchStatefulSet_ImagePullSecrets(t *testing.T) { } func TestBuildMeilisearchStatefulSet_CustomReplicas(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") project.Spec.Meilisearch = &supabasev1alpha1.MeilisearchSpec{Replicas: 2} secretNames := newTestSecretNames() diff --git a/internal/resources/deployments/powersync.go b/internal/resources/deployments/powersync.go index c0ba335..f02f348 100644 --- a/internal/resources/deployments/powersync.go +++ b/internal/resources/deployments/powersync.go @@ -33,9 +33,9 @@ import ( ) const ( - PowersyncAPIComponentName = "powersync-api" - PowersyncReplicationComponentName = "powersync-replication" - PowersyncCompactComponentName = "powersync-compact" + PowersyncAPIComponentName = "powersync-api" + PowersyncReplicationComponentName = "powersync-replication" + PowersyncCompactComponentName = "powersync-compact" PowersyncHTTPPort int32 = 8080 PowersyncMetricsPort int32 = 9464 ) @@ -355,9 +355,9 @@ func powersyncVolumes(project *supabasev1alpha1.SupabaseProject) []corev1.Volume } } -func normalizePowersyncResources(resources corev1.ResourceRequirements, defaults corev1.ResourceRequirements) corev1.ResourceRequirements { +func normalizePowersyncResources(resources corev1.ResourceRequirements, fallback corev1.ResourceRequirements) corev1.ResourceRequirements { if len(resources.Requests) == 0 && len(resources.Limits) == 0 { - return defaults + return fallback } return resources } diff --git a/internal/resources/deployments/powersync_test.go b/internal/resources/deployments/powersync_test.go index 2ab0681..15de8ee 100644 --- a/internal/resources/deployments/powersync_test.go +++ b/internal/resources/deployments/powersync_test.go @@ -8,7 +8,7 @@ import ( ) func TestPowersyncNames(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") tests := []struct { name string @@ -31,7 +31,7 @@ func TestPowersyncNames(t *testing.T) { } func TestBuildPowersyncAPIDeployment(t *testing.T) { - project := newTestProject("my-app", "test-ns") + project := newTestProject(testNamespace) secretNames := newTestSecretNames() dep := BuildPowersyncAPIDeployment(project, secretNames) @@ -39,8 +39,8 @@ func TestBuildPowersyncAPIDeployment(t *testing.T) { if dep.Name != "my-app-powersync-api" { t.Errorf("Name = %q, want %q", dep.Name, "my-app-powersync-api") } - if dep.Namespace != "test-ns" { - t.Errorf("Namespace = %q, want %q", dep.Namespace, "test-ns") + if dep.Namespace != testNamespace { + t.Errorf("Namespace = %q, want %q", dep.Namespace, testNamespace) } // Default replicas = 1 (NormalizeReplicas(0) = 1) @@ -99,7 +99,7 @@ func TestBuildPowersyncAPIDeployment(t *testing.T) { } func TestBuildPowersyncAPIDeployment_CustomReplicas(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") project.Spec.Powersync.API.Replicas = 3 secretNames := newTestSecretNames() @@ -110,7 +110,7 @@ func TestBuildPowersyncAPIDeployment_CustomReplicas(t *testing.T) { } func TestBuildPowersyncAPIDeployment_CustomNodeOptions(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") project.Spec.Powersync.API.NodeOptions = "--max-old-space-size=512" secretNames := newTestSecretNames() @@ -129,7 +129,7 @@ func TestBuildPowersyncAPIDeployment_CustomNodeOptions(t *testing.T) { } func TestBuildPowersyncReplicationDeployment(t *testing.T) { - project := newTestProject("my-app", "test-ns") + project := newTestProject(testNamespace) secretNames := newTestSecretNames() dep := BuildPowersyncReplicationDeployment(project, secretNames) @@ -168,7 +168,7 @@ func TestBuildPowersyncReplicationDeployment(t *testing.T) { } func TestBuildPowersyncCompactCronJob(t *testing.T) { - project := newTestProject("my-app", "test-ns") + project := newTestProject(testNamespace) secretNames := newTestSecretNames() cj := BuildPowersyncCompactCronJob(project, secretNames) @@ -176,8 +176,8 @@ func TestBuildPowersyncCompactCronJob(t *testing.T) { if cj.Name != "my-app-powersync-compact" { t.Errorf("Name = %q, want %q", cj.Name, "my-app-powersync-compact") } - if cj.Namespace != "test-ns" { - t.Errorf("Namespace = %q, want %q", cj.Namespace, "test-ns") + if cj.Namespace != testNamespace { + t.Errorf("Namespace = %q, want %q", cj.Namespace, testNamespace) } // Default schedule @@ -193,7 +193,7 @@ func TestBuildPowersyncCompactCronJob(t *testing.T) { } func TestBuildPowersyncCompactCronJob_CustomSchedule(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") project.Spec.Powersync.Compact.Schedule = "0 2 * * *" secretNames := newTestSecretNames() @@ -204,7 +204,7 @@ func TestBuildPowersyncCompactCronJob_CustomSchedule(t *testing.T) { } func TestBuildPowersyncCompactCronJob_Disabled(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") project.Spec.Powersync.Compact.Enabled = false secretNames := newTestSecretNames() @@ -215,7 +215,7 @@ func TestBuildPowersyncCompactCronJob_Disabled(t *testing.T) { } func TestBuildPowersyncEnvVars(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") secretNames := newTestSecretNames() dep := BuildPowersyncAPIDeployment(project, secretNames) diff --git a/internal/resources/deployments/redis.go b/internal/resources/deployments/redis.go index e4abfd6..3dbe1a2 100644 --- a/internal/resources/deployments/redis.go +++ b/internal/resources/deployments/redis.go @@ -32,7 +32,7 @@ import ( ) const ( - RedisComponentName = "sequin-redis" + RedisComponentName = "sequin-redis" RedisPort int32 = 6379 ) diff --git a/internal/resources/deployments/redis_test.go b/internal/resources/deployments/redis_test.go index 92d597f..dac0843 100644 --- a/internal/resources/deployments/redis_test.go +++ b/internal/resources/deployments/redis_test.go @@ -8,32 +8,32 @@ import ( ) func TestSequinRedisStatefulSetName(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") got := SequinRedisStatefulSetName(project) - if got != "my-app-sequin-redis" { - t.Errorf("SequinRedisStatefulSetName() = %q, want %q", got, "my-app-sequin-redis") + if got != testProjectName+"-sequin-redis" { + t.Errorf("SequinRedisStatefulSetName() = %q, want %q", got, testProjectName+"-sequin-redis") } } func TestSequinRedisServiceName(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") got := SequinRedisServiceName(project) - if got != "my-app-sequin-redis" { - t.Errorf("SequinRedisServiceName() = %q, want %q", got, "my-app-sequin-redis") + if got != testProjectName+"-sequin-redis" { + t.Errorf("SequinRedisServiceName() = %q, want %q", got, testProjectName+"-sequin-redis") } } func TestBuildSequinRedisStatefulSet(t *testing.T) { - project := newTestProject("my-app", "test-ns") + project := newTestProject(testNamespace) sts := BuildSequinRedisStatefulSet(project) // Metadata - if sts.Name != "my-app-sequin-redis" { - t.Errorf("Name = %q, want %q", sts.Name, "my-app-sequin-redis") + if sts.Name != testProjectName+"-sequin-redis" { + t.Errorf("Name = %q, want %q", sts.Name, testProjectName+"-sequin-redis") } - if sts.Namespace != "test-ns" { - t.Errorf("Namespace = %q, want %q", sts.Namespace, "test-ns") + if sts.Namespace != testNamespace { + t.Errorf("Namespace = %q, want %q", sts.Namespace, testNamespace) } // Always single replica @@ -42,8 +42,8 @@ func TestBuildSequinRedisStatefulSet(t *testing.T) { } // ServiceName matches StatefulSet name - if sts.Spec.ServiceName != "my-app-sequin-redis" { - t.Errorf("ServiceName = %q, want %q", sts.Spec.ServiceName, "my-app-sequin-redis") + if sts.Spec.ServiceName != testProjectName+"-sequin-redis" { + t.Errorf("ServiceName = %q, want %q", sts.Spec.ServiceName, testProjectName+"-sequin-redis") } // Container @@ -86,12 +86,14 @@ func TestBuildSequinRedisStatefulSet(t *testing.T) { t.Error("expected RunAsNonRoot=true") } - // Container security: drop ALL capabilities - containerSec := c.SecurityContext - if containerSec == nil || !*containerSec.AllowPrivilegeEscalation { - // AllowPrivilegeEscalation should be false + // Container security: drop ALL capabilities, no privilege escalation + if c.SecurityContext == nil { + t.Fatal("expected container security context") } - if containerSec == nil || len(containerSec.Capabilities.Drop) == 0 { + if c.SecurityContext.AllowPrivilegeEscalation == nil || *c.SecurityContext.AllowPrivilegeEscalation { + t.Error("expected AllowPrivilegeEscalation=false") + } + if c.SecurityContext.Capabilities == nil || len(c.SecurityContext.Capabilities.Drop) == 0 { t.Error("expected dropped capabilities") } @@ -112,7 +114,7 @@ func TestBuildSequinRedisStatefulSet(t *testing.T) { } func TestBuildSequinRedisStatefulSet_CustomStorage(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") project.Spec.Sequin.Redis.Storage.Size = "5Gi" project.Spec.Sequin.Redis.Storage.StorageClass = "fast-ssd" @@ -129,14 +131,14 @@ func TestBuildSequinRedisStatefulSet_CustomStorage(t *testing.T) { } func TestBuildSequinRedisService(t *testing.T) { - project := newTestProject("my-app", "test-ns") + project := newTestProject(testNamespace) svc := BuildSequinRedisService(project) - if svc.Name != "my-app-sequin-redis" { - t.Errorf("Name = %q, want %q", svc.Name, "my-app-sequin-redis") + if svc.Name != testProjectName+"-sequin-redis" { + t.Errorf("Name = %q, want %q", svc.Name, testProjectName+"-sequin-redis") } - if svc.Namespace != "test-ns" { - t.Errorf("Namespace = %q, want %q", svc.Namespace, "test-ns") + if svc.Namespace != testNamespace { + t.Errorf("Namespace = %q, want %q", svc.Namespace, testNamespace) } if svc.Spec.Type != corev1.ServiceTypeClusterIP { t.Errorf("Type = %q, want ClusterIP", svc.Spec.Type) diff --git a/internal/resources/deployments/sequin_test.go b/internal/resources/deployments/sequin_test.go index c4c48d9..9fc3ea2 100644 --- a/internal/resources/deployments/sequin_test.go +++ b/internal/resources/deployments/sequin_test.go @@ -11,10 +11,15 @@ import ( "github.com/GuionAI/cloudnative-supabase/internal/resources/defaults" ) -func newTestProject(name, namespace string) *supabasev1alpha1.SupabaseProject { +const ( + testProjectName = "my-app" + testNamespace = "test-ns" +) + +func newTestProject(namespace string) *supabasev1alpha1.SupabaseProject { return &supabasev1alpha1.SupabaseProject{ ObjectMeta: metav1.ObjectMeta{ - Name: name, + Name: testProjectName, Namespace: namespace, }, Spec: supabasev1alpha1.SupabaseProjectSpec{ @@ -29,17 +34,17 @@ func newTestProject(name, namespace string) *supabasev1alpha1.SupabaseProject { func newTestSecretNames() *supabasev1alpha1.SecretNamesStatus { return &supabasev1alpha1.SecretNamesStatus{ - JWT: "test-jwt", - Sequin: "test-sequin", - SequinPassword: "test-sequin-password", + JWT: "test-jwt", + Sequin: "test-sequin", + SequinPassword: "test-sequin-password", SequinReplicationPassword: "test-sequin-replication-password", - PowersyncStoragePassword: "test-powersync-storage-password", - MeilisearchMasterKey: "test-meilisearch-master-key", + PowersyncStoragePassword: "test-powersync-storage-password", + MeilisearchMasterKey: "test-meilisearch-master-key", } } func TestSequinDeploymentName(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") got := SequinDeploymentName(project) want := "my-app-sequin" if got != want { @@ -135,7 +140,7 @@ func TestResolvePullPolicy(t *testing.T) { } func TestBuildSequinDeployment(t *testing.T) { - project := newTestProject("my-app", "test-ns") + project := newTestProject(testNamespace) secretNames := newTestSecretNames() dep := BuildSequinDeployment(project, secretNames) @@ -195,7 +200,7 @@ func TestBuildSequinDeployment(t *testing.T) { } func TestBuildSequinDeployment_CustomReplicas(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") project.Spec.Sequin.Replicas = 3 secretNames := newTestSecretNames() @@ -206,7 +211,7 @@ func TestBuildSequinDeployment_CustomReplicas(t *testing.T) { } func TestBuildSequinDeployment_ExternalRedis(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") project.Spec.Sequin.Redis.External = &supabasev1alpha1.ExternalRedisSpec{ Host: "redis.infra.svc", Port: 6380, @@ -230,7 +235,7 @@ func TestBuildSequinDeployment_ExternalRedis(t *testing.T) { } func TestBuildSequinDeployment_BundledRedis(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") // External is nil by default - should use bundled Redis URL secretNames := newTestSecretNames() @@ -251,7 +256,7 @@ func TestBuildSequinDeployment_BundledRedis(t *testing.T) { } func TestBuildSequinDeployment_EnvVars(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") secretNames := newTestSecretNames() dep := BuildSequinDeployment(project, secretNames) diff --git a/internal/resources/secrets/secrets_test.go b/internal/resources/secrets/secrets_test.go index 6daad42..76a0868 100644 --- a/internal/resources/secrets/secrets_test.go +++ b/internal/resources/secrets/secrets_test.go @@ -8,10 +8,15 @@ import ( supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" ) -func newTestProject(name, namespace string) *supabasev1alpha1.SupabaseProject { +const ( + testProjectName = "my-app" + testNamespace = "test-ns" +) + +func newTestProject(namespace string) *supabasev1alpha1.SupabaseProject { return &supabasev1alpha1.SupabaseProject{ ObjectMeta: metav1.ObjectMeta{ - Name: name, + Name: testProjectName, Namespace: namespace, }, Spec: supabasev1alpha1.SupabaseProjectSpec{ @@ -23,7 +28,7 @@ func newTestProject(name, namespace string) *supabasev1alpha1.SupabaseProject { } func TestSequinSecretNames(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") sequin, sequinPw, sequinRepl := SequinSecretNames(project) @@ -39,7 +44,7 @@ func TestSequinSecretNames(t *testing.T) { } func TestPowersyncSecretNames(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") got := PowersyncSecretNames(project) if got != "my-app-powersync-storage-password" { t.Errorf("got %q, want %q", got, "my-app-powersync-storage-password") @@ -66,7 +71,7 @@ func TestMeilisearchSecretName(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") project.Spec.Meilisearch.MasterKeySecretRef = tt.masterKeyRef got := MeilisearchSecretName(project) @@ -78,7 +83,7 @@ func TestMeilisearchSecretName(t *testing.T) { } func TestGenerateSequinSecrets(t *testing.T) { - project := newTestProject("my-app", "test-ns") + project := newTestProject(testNamespace) secrets, err := GenerateSequinSecrets(project) if err != nil { @@ -94,8 +99,8 @@ func TestGenerateSequinSecrets(t *testing.T) { if appSecret.Name != "my-app-sequin" { t.Errorf("app secret name = %q, want %q", appSecret.Name, "my-app-sequin") } - if appSecret.Namespace != "test-ns" { - t.Errorf("app secret namespace = %q, want %q", appSecret.Namespace, "test-ns") + if appSecret.Namespace != testNamespace { + t.Errorf("app secret namespace = %q, want %q", appSecret.Namespace, testNamespace) } requiredAppKeys := []string{"secretKeyBase", "vaultKey", "apiToken"} @@ -130,7 +135,7 @@ func TestGenerateSequinSecrets(t *testing.T) { } func TestGeneratePowersyncSecrets(t *testing.T) { - project := newTestProject("my-app", "test-ns") + project := newTestProject(testNamespace) secrets, err := GeneratePowersyncSecrets(project) if err != nil { @@ -154,7 +159,7 @@ func TestGeneratePowersyncSecrets(t *testing.T) { } func TestGenerateMeilisearchSecrets(t *testing.T) { - project := newTestProject("my-app", "test-ns") + project := newTestProject(testNamespace) secrets, err := GenerateMeilisearchSecrets(project) if err != nil { @@ -169,8 +174,8 @@ func TestGenerateMeilisearchSecrets(t *testing.T) { if secret.Name != "my-app-meilisearch-master-key" { t.Errorf("name = %q, want %q", secret.Name, "my-app-meilisearch-master-key") } - if secret.Namespace != "test-ns" { - t.Errorf("namespace = %q, want %q", secret.Namespace, "test-ns") + if secret.Namespace != testNamespace { + t.Errorf("namespace = %q, want %q", secret.Namespace, testNamespace) } // masterKey should be 64 chars (32 bytes hex) @@ -181,7 +186,7 @@ func TestGenerateMeilisearchSecrets(t *testing.T) { } func TestGenerateMeilisearchSecrets_ExistingRef(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") project.Spec.Meilisearch.MasterKeySecretRef = "my-existing-key" secrets, err := GenerateMeilisearchSecrets(project) @@ -195,7 +200,7 @@ func TestGenerateMeilisearchSecrets_ExistingRef(t *testing.T) { } func TestGenerateSecrets_Uniqueness(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") secrets1, err := GenerateSequinSecrets(project) if err != nil { diff --git a/internal/resources/services/services_test.go b/internal/resources/services/services_test.go index dc86edc..8e8ca03 100644 --- a/internal/resources/services/services_test.go +++ b/internal/resources/services/services_test.go @@ -9,24 +9,29 @@ import ( supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" ) -func newTestProject(name, namespace string) *supabasev1alpha1.SupabaseProject { +const ( + testProjectName = "my-app" + testNamespace = "test-ns" +) + +func newTestProject(namespace string) *supabasev1alpha1.SupabaseProject { return &supabasev1alpha1.SupabaseProject{ ObjectMeta: metav1.ObjectMeta{ - Name: name, + Name: testProjectName, Namespace: namespace, }, } } func TestBuildSequinService(t *testing.T) { - project := newTestProject("my-app", "test-ns") + project := newTestProject(testNamespace) svc := BuildSequinService(project) if svc.Name != "my-app-sequin" { t.Errorf("Name = %q, want %q", svc.Name, "my-app-sequin") } - if svc.Namespace != "test-ns" { - t.Errorf("Namespace = %q, want %q", svc.Namespace, "test-ns") + if svc.Namespace != testNamespace { + t.Errorf("Namespace = %q, want %q", svc.Namespace, testNamespace) } if svc.Spec.Type != corev1.ServiceTypeClusterIP { t.Errorf("Type = %q, want ClusterIP", svc.Spec.Type) @@ -51,14 +56,14 @@ func TestBuildSequinService(t *testing.T) { } func TestBuildPowersyncAPIService(t *testing.T) { - project := newTestProject("my-app", "test-ns") + project := newTestProject(testNamespace) svc := BuildPowersyncAPIService(project) if svc.Name != "my-app-powersync-api" { t.Errorf("Name = %q, want %q", svc.Name, "my-app-powersync-api") } - if svc.Namespace != "test-ns" { - t.Errorf("Namespace = %q, want %q", svc.Namespace, "test-ns") + if svc.Namespace != testNamespace { + t.Errorf("Namespace = %q, want %q", svc.Namespace, testNamespace) } if svc.Spec.Type != corev1.ServiceTypeClusterIP { t.Errorf("Type = %q, want ClusterIP", svc.Spec.Type) @@ -83,14 +88,14 @@ func TestBuildPowersyncAPIService(t *testing.T) { } func TestBuildMeilisearchService(t *testing.T) { - project := newTestProject("my-app", "test-ns") + project := newTestProject(testNamespace) svc := BuildMeilisearchService(project) if svc.Name != "my-app-meilisearch" { t.Errorf("Name = %q, want %q", svc.Name, "my-app-meilisearch") } - if svc.Namespace != "test-ns" { - t.Errorf("Namespace = %q, want %q", svc.Namespace, "test-ns") + if svc.Namespace != testNamespace { + t.Errorf("Namespace = %q, want %q", svc.Namespace, testNamespace) } if svc.Spec.Type != corev1.ServiceTypeClusterIP { t.Errorf("Type = %q, want ClusterIP", svc.Spec.Type) @@ -106,7 +111,7 @@ func TestBuildMeilisearchService(t *testing.T) { } func TestServiceLabels(t *testing.T) { - project := newTestProject("my-app", "default") + project := newTestProject("default") tests := []struct { name string From 67a5733d7fa81edac11dd977319c8eb33bf9c3b2 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 7 Feb 2026 22:59:23 +0800 Subject: [PATCH 12/21] fix(controller): CDC Job completion tracking and spec-change idempotency Fix two critical bugs in CDC permissions reconciliation: 1. createOrCheckJob now returns (completed bool, err error) instead of just error. When the Job is still running, reconcileCDCPermissions sets CDCReady=False/Reason=JobRunning and requeues instead of prematurely setting CDCReady=True and deploying Sequin/Powersync. 2. A SHA-256 hash of the CDC setup script is stored as an annotation on the Job. When the spec changes (e.g., Powersync added after Sequin was already enabled), the hash mismatch triggers deletion of the old Job so a new one runs with the updated permissions. Co-Authored-By: Claude Opus 4.6 --- .../controller/supabaseproject_controller.go | 93 +++++++++++++++---- 1 file changed, 73 insertions(+), 20 deletions(-) diff --git a/internal/controller/supabaseproject_controller.go b/internal/controller/supabaseproject_controller.go index e4b5494..98ef579 100644 --- a/internal/controller/supabaseproject_controller.go +++ b/internal/controller/supabaseproject_controller.go @@ -18,6 +18,8 @@ package controller import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "time" @@ -153,8 +155,8 @@ func (r *SupabaseProjectReconciler) Reconcile(ctx context.Context, req ctrl.Requ // Phase 6: CDC Services (after core services) if project.Spec.Sequin != nil || project.Spec.Powersync != nil { - if err := r.reconcileCDCPermissions(ctx, project); err != nil { - return ctrl.Result{}, err + if result, err := r.reconcileCDCPermissions(ctx, project); err != nil || result.RequeueAfter > 0 { + return result, err } } if project.Spec.Sequin != nil { @@ -1148,8 +1150,10 @@ func (r *SupabaseProjectReconciler) createOrUpdateScheduledBackup(ctx context.Co return nil } -// reconcileCDCPermissions ensures CDC permissions are applied via dbmate Job -func (r *SupabaseProjectReconciler) reconcileCDCPermissions(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { +// reconcileCDCPermissions ensures CDC permissions are applied via a Job. +// Returns RequeueAfter when the Job is still running so we don't proceed +// to deploy Sequin/Powersync before permissions exist. +func (r *SupabaseProjectReconciler) reconcileCDCPermissions(ctx context.Context, project *supabasev1alpha1.SupabaseProject) (ctrl.Result, error) { log := logf.FromContext(ctx) log.Info("Reconciling CDC permissions") @@ -1160,23 +1164,44 @@ func (r *SupabaseProjectReconciler) reconcileCDCPermissions(ctx context.Context, if err := r.createOrUpdateConfigMap(ctx, project, configMap); err != nil { r.setCondition(project, supabasev1alpha1.ConditionTypeCDCReady, metav1.ConditionFalse, "ConfigMapFailed", err.Error()) if statusErr := r.Status().Update(ctx, project); statusErr != nil { - return statusErr + return ctrl.Result{}, statusErr } - return err + return ctrl.Result{}, err } + // Compute a hash of the CDC script so we can detect spec changes + scriptHash := cdcScriptHash(project) + // Create or check CDC permissions Job job := jobs.BuildCDCPermissionsJob(project, secretNames) - if err := r.createOrCheckJob(ctx, project, job); err != nil { + completed, err := r.createOrCheckJob(ctx, project, job, scriptHash) + if err != nil { r.setCondition(project, supabasev1alpha1.ConditionTypeCDCReady, metav1.ConditionFalse, "JobFailed", err.Error()) if statusErr := r.Status().Update(ctx, project); statusErr != nil { - return statusErr + return ctrl.Result{}, statusErr } - return err + return ctrl.Result{}, err } - r.setCondition(project, supabasev1alpha1.ConditionTypeCDCReady, metav1.ConditionTrue, "CDCPermissionsApplied", "CDC permissions Job created") - return nil + if !completed { + r.setCondition(project, supabasev1alpha1.ConditionTypeCDCReady, metav1.ConditionFalse, "JobRunning", "CDC permissions Job is still running") + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return ctrl.Result{}, statusErr + } + return ctrl.Result{RequeueAfter: RequeueDelay}, nil + } + + r.setCondition(project, supabasev1alpha1.ConditionTypeCDCReady, metav1.ConditionTrue, "CDCPermissionsApplied", "CDC permissions applied successfully") + return ctrl.Result{}, nil +} + +// cdcScriptHash computes a SHA-256 hash of the CDC setup script content. +// Used to detect when the spec changes (e.g., Powersync added after Sequin) +// so the Job can be recreated with the new permissions. +func cdcScriptHash(project *supabasev1alpha1.SupabaseProject) string { + cm := jobs.BuildCDCMigrationsConfigMap(project) + h := sha256.Sum256([]byte(cm.Data["setup.sh"])) + return hex.EncodeToString(h[:]) } // reconcileSequin deploys the Sequin service (and bundled Redis if external is not configured) @@ -1403,38 +1428,66 @@ func (r *SupabaseProjectReconciler) createOrUpdateCronJob(ctx context.Context, p return r.Update(ctx, existing) } -// createOrCheckJob creates a Job if it doesn't exist, or checks status of existing Job -func (r *SupabaseProjectReconciler) createOrCheckJob(ctx context.Context, project *supabasev1alpha1.SupabaseProject, job *batchv1.Job) error { +const cdcScriptHashAnnotation = "supabase.guion.dev/cdc-script-hash" + +// createOrCheckJob creates a Job if it doesn't exist, or checks status of an existing Job. +// scriptHash is compared against an annotation on the existing Job to detect spec changes +// (e.g., Powersync added after Sequin). When the hash changes, the old Job is deleted +// and a new one is created. +// Returns (true, nil) when the Job has completed successfully, (false, nil) when still +// running or just created, and (false, err) on failure. +func (r *SupabaseProjectReconciler) createOrCheckJob(ctx context.Context, project *supabasev1alpha1.SupabaseProject, job *batchv1.Job, scriptHash string) (bool, error) { log := logf.FromContext(ctx) // Set owner reference if err := controllerutil.SetControllerReference(project, job, r.Scheme); err != nil { - return err + return false, err } + // Annotate the Job with the script hash + if job.Annotations == nil { + job.Annotations = make(map[string]string) + } + job.Annotations[cdcScriptHashAnnotation] = scriptHash + // Check if Job exists existing := &batchv1.Job{} err := r.Get(ctx, types.NamespacedName{Name: job.Name, Namespace: job.Namespace}, existing) if err != nil { if apierrors.IsNotFound(err) { log.Info("Creating Job", "name", job.Name) - return r.Create(ctx, job) + return false, r.Create(ctx, job) } - return err + return false, err + } + + // Check if the script has changed since the existing Job was created. + // If the hash differs, delete the old Job so a fresh one runs with the new script. + existingHash := existing.Annotations[cdcScriptHashAnnotation] + if existingHash != scriptHash { + log.Info("CDC script changed, recreating Job", "name", job.Name, "oldHash", existingHash, "newHash", scriptHash) + propagation := metav1.DeletePropagationForeground + if err := r.Delete(ctx, existing, &client.DeleteOptions{ + PropagationPolicy: &propagation, + }); err != nil && !apierrors.IsNotFound(err) { + return false, fmt.Errorf("failed to delete outdated Job %s: %w", job.Name, err) + } + // Requeue — the next reconcile will create the new Job once the old one is gone + return false, nil } - // Job exists - check completion status + // Job exists with matching hash — check completion status if existing.Status.Succeeded > 0 { log.V(1).Info("Job completed successfully", "name", job.Name) - return nil + return true, nil } if existing.Status.Failed > 0 && existing.Status.Active == 0 { - return fmt.Errorf("job %s has failed", job.Name) + return false, fmt.Errorf("job %s has failed", job.Name) } // Job still running log.V(1).Info("Job still running", "name", job.Name, "active", existing.Status.Active) - return nil + return false, nil } func (r *SupabaseProjectReconciler) setCondition(project *supabasev1alpha1.SupabaseProject, conditionType string, status metav1.ConditionStatus, reason, message string) { From eac6d277027bc0b94b1a0e7d08ffa3ad45fd9459 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 7 Feb 2026 23:40:23 +0800 Subject: [PATCH 13/21] feat(powersync): add dedicated powersync_replication role for CDC Split Powersync's database concerns into two distinct roles: - powersync_storage: internal sync state tables (no REPLICATION) - powersync_replication: CDC/WAL reading (REPLICATION + BypassRLS) Previously powersync_storage was used for both the storage and replication connections, but lacked the REPLICATION privilege needed for logical replication. The replication URI now uses the dedicated powersync_replication role with proper credentials. Co-Authored-By: Claude Opus 4.6 --- api/v1alpha1/supabaseproject_types.go | 4 +++ .../supabase.guion.dev_supabaseprojects.yaml | 4 +++ .../supabase.guion.dev_supabaseprojects.yaml | 4 +++ .../controller/supabaseproject_controller.go | 6 ++-- internal/resources/cnpg/cluster.go | 20 +++++++++-- internal/resources/configmaps/powersync.go | 2 +- internal/resources/deployments/powersync.go | 20 ++++++++--- .../resources/deployments/powersync_test.go | 2 +- internal/resources/deployments/sequin_test.go | 13 +++---- internal/resources/jobs/cdc_permissions.go | 10 +++--- internal/resources/secrets/secrets.go | 14 ++++++-- internal/resources/secrets/secrets_test.go | 34 ++++++++++++------- 12 files changed, 96 insertions(+), 37 deletions(-) diff --git a/api/v1alpha1/supabaseproject_types.go b/api/v1alpha1/supabaseproject_types.go index 1b4ac01..28b7edf 100644 --- a/api/v1alpha1/supabaseproject_types.go +++ b/api/v1alpha1/supabaseproject_types.go @@ -879,6 +879,10 @@ type SecretNamesStatus struct { // +optional PowersyncStoragePassword string `json:"powersyncStoragePassword,omitempty"` + // PowersyncReplicationPassword is the name of the powersync_replication role password secret + // +optional + PowersyncReplicationPassword string `json:"powersyncReplicationPassword,omitempty"` + // MeilisearchMasterKey is the name of the Meilisearch master key secret // +optional MeilisearchMasterKey string `json:"meilisearchMasterKey,omitempty"` diff --git a/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml b/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml index bcb05a2..4cf1efb 100644 --- a/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml +++ b/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml @@ -1818,6 +1818,10 @@ spec: description: MeilisearchMasterKey is the name of the Meilisearch master key secret type: string + powersyncReplicationPassword: + description: PowersyncReplicationPassword is the name of the powersync_replication + role password secret + type: string powersyncStoragePassword: description: PowersyncStoragePassword is the name of the powersync_storage role password secret diff --git a/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml b/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml index bcb05a2..4cf1efb 100644 --- a/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml +++ b/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml @@ -1818,6 +1818,10 @@ spec: description: MeilisearchMasterKey is the name of the Meilisearch master key secret type: string + powersyncReplicationPassword: + description: PowersyncReplicationPassword is the name of the powersync_replication + role password secret + type: string powersyncStoragePassword: description: PowersyncStoragePassword is the name of the powersync_storage role password secret diff --git a/internal/controller/supabaseproject_controller.go b/internal/controller/supabaseproject_controller.go index 98ef579..3b8a407 100644 --- a/internal/controller/supabaseproject_controller.go +++ b/internal/controller/supabaseproject_controller.go @@ -453,13 +453,14 @@ func (r *SupabaseProjectReconciler) reconcileSequinSecrets(ctx context.Context, func (r *SupabaseProjectReconciler) reconcilePowersyncSecrets(ctx context.Context, project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) error { log := logf.FromContext(ctx) - storagePwdName := secrets.PowersyncSecretNames(project) + storagePwdName, replPwdName := secrets.PowersyncSecretNames(project) - // Check if secret exists + // Check if both secrets exist (use storage password as the sentinel) existing := &corev1.Secret{} if err := r.Get(ctx, types.NamespacedName{Name: storagePwdName, Namespace: project.Namespace}, existing); err == nil { log.Info("Powersync secrets already exist, syncing status") secretNames.PowersyncStoragePassword = storagePwdName + secretNames.PowersyncReplicationPassword = replPwdName return nil } else if !apierrors.IsNotFound(err) { return err @@ -487,6 +488,7 @@ func (r *SupabaseProjectReconciler) reconcilePowersyncSecrets(ctx context.Contex } secretNames.PowersyncStoragePassword = storagePwdName + secretNames.PowersyncReplicationPassword = replPwdName return nil } diff --git a/internal/resources/cnpg/cluster.go b/internal/resources/cnpg/cluster.go index 1c4f156..17c7d00 100644 --- a/internal/resources/cnpg/cluster.go +++ b/internal/resources/cnpg/cluster.go @@ -202,7 +202,7 @@ func buildAllRoles(project *supabasev1alpha1.SupabaseProject, secretNames *supab if project.Spec.Sequin != nil && secretNames.SequinPassword != "" { roles = append(roles, BuildSequinRoles(secretNames)...) } - if project.Spec.Powersync != nil && secretNames.PowersyncStoragePassword != "" { + if project.Spec.Powersync != nil && secretNames.PowersyncStoragePassword != "" && secretNames.PowersyncReplicationPassword != "" { roles = append(roles, BuildPowersyncRoles(secretNames)...) } return roles @@ -234,7 +234,10 @@ func BuildSequinRoles(secretNames *supabasev1alpha1.SecretNamesStatus) []cnpgv1. } } -// BuildPowersyncRoles returns additional CNPG roles required for Powersync +// BuildPowersyncRoles returns additional CNPG roles required for Powersync. +// Two roles are needed: +// - powersync_storage: stores Powersync's internal sync state (checkpoints, buckets) +// - powersync_replication: reads the WAL via logical replication for CDC func BuildPowersyncRoles(secretNames *supabasev1alpha1.SecretNamesStatus) []cnpgv1.RoleConfiguration { return []cnpgv1.RoleConfiguration{ { @@ -244,7 +247,18 @@ func BuildPowersyncRoles(secretNames *supabasev1alpha1.SecretNamesStatus) []cnpg PasswordSecret: &cnpgv1.LocalObjectReference{ Name: secretNames.PowersyncStoragePassword, }, - Comment: "Powersync storage role", + Comment: "Powersync internal storage role", + }, + { + Name: "powersync_replication", + Ensure: cnpgv1.EnsurePresent, + Login: true, + Replication: true, + BypassRLS: true, + PasswordSecret: &cnpgv1.LocalObjectReference{ + Name: secretNames.PowersyncReplicationPassword, + }, + Comment: "Powersync CDC replication role", }, } } diff --git a/internal/resources/configmaps/powersync.go b/internal/resources/configmaps/powersync.go index 1bf40ab..d6f8104 100644 --- a/internal/resources/configmaps/powersync.go +++ b/internal/resources/configmaps/powersync.go @@ -95,7 +95,7 @@ func BuildPowersyncConfigMap(project *supabasev1alpha1.SupabaseProject, dbHost s { Type: "postgresql", // Will be overridden by PS_POWERSYNC_REPLICATION_URI env var - URI: fmt.Sprintf("postgresql://powersync_storage@%s:5432/supabase?sslmode=disable", dbHost), + URI: fmt.Sprintf("postgresql://powersync_replication@%s:5432/supabase?sslmode=disable", dbHost), Tag: "default", }, }, diff --git a/internal/resources/deployments/powersync.go b/internal/resources/deployments/powersync.go index f02f348..4a68b23 100644 --- a/internal/resources/deployments/powersync.go +++ b/internal/resources/deployments/powersync.go @@ -277,9 +277,9 @@ func buildPowersyncEnv(project *supabasev1alpha1.SupabaseProject, secretNames *s return []corev1.EnvVar{ {Name: "POWERSYNC_CONFIG_PATH", Value: "/powersync/config/config.json"}, {Name: "NODE_OPTIONS", Value: nodeOptions}, - // Database password from secret for connection string construction + // Storage password (powersync_storage role — internal sync state tables) { - Name: "PS_PG_PASSWORD", + Name: "PS_STORAGE_PASSWORD", ValueFrom: &corev1.EnvVarSource{ SecretKeyRef: &corev1.SecretKeySelector{ LocalObjectReference: corev1.LocalObjectReference{ @@ -289,14 +289,26 @@ func buildPowersyncEnv(project *supabasev1alpha1.SupabaseProject, secretNames *s }, }, }, + // Replication password (powersync_replication role — CDC/WAL reading) + { + Name: "PS_REPLICATION_PASSWORD", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secretNames.PowersyncReplicationPassword, + }, + Key: "password", + }, + }, + }, // PowerSync resolves {{ env.VAR }} in config.json { Name: "PS_POWERSYNC_STORAGE_URI", - Value: fmt.Sprintf("postgresql://powersync_storage:$(PS_PG_PASSWORD)@%s:5432/supabase?sslmode=disable", dbHost), + Value: fmt.Sprintf("postgresql://powersync_storage:$(PS_STORAGE_PASSWORD)@%s:5432/supabase?sslmode=disable", dbHost), }, { Name: "PS_POWERSYNC_REPLICATION_URI", - Value: fmt.Sprintf("postgresql://powersync_storage:$(PS_PG_PASSWORD)@%s:5432/supabase?sslmode=disable", dbHost), + Value: fmt.Sprintf("postgresql://powersync_replication:$(PS_REPLICATION_PASSWORD)@%s:5432/supabase?sslmode=disable", dbHost), }, // JWT secret for client authentication { diff --git a/internal/resources/deployments/powersync_test.go b/internal/resources/deployments/powersync_test.go index 15de8ee..6eb28da 100644 --- a/internal/resources/deployments/powersync_test.go +++ b/internal/resources/deployments/powersync_test.go @@ -226,7 +226,7 @@ func TestBuildPowersyncEnvVars(t *testing.T) { envMap[e.Name] = struct{}{} } - required := []string{"POWERSYNC_CONFIG_PATH", "NODE_OPTIONS", "PS_PG_PASSWORD", "PS_POWERSYNC_STORAGE_URI", "PS_POWERSYNC_REPLICATION_URI", "PS_JWT_SECRET"} + required := []string{"POWERSYNC_CONFIG_PATH", "NODE_OPTIONS", "PS_STORAGE_PASSWORD", "PS_REPLICATION_PASSWORD", "PS_POWERSYNC_STORAGE_URI", "PS_POWERSYNC_REPLICATION_URI", "PS_JWT_SECRET"} for _, name := range required { if _, ok := envMap[name]; !ok { t.Errorf("missing required env var: %s", name) diff --git a/internal/resources/deployments/sequin_test.go b/internal/resources/deployments/sequin_test.go index 9fc3ea2..4db787d 100644 --- a/internal/resources/deployments/sequin_test.go +++ b/internal/resources/deployments/sequin_test.go @@ -34,12 +34,13 @@ func newTestProject(namespace string) *supabasev1alpha1.SupabaseProject { func newTestSecretNames() *supabasev1alpha1.SecretNamesStatus { return &supabasev1alpha1.SecretNamesStatus{ - JWT: "test-jwt", - Sequin: "test-sequin", - SequinPassword: "test-sequin-password", - SequinReplicationPassword: "test-sequin-replication-password", - PowersyncStoragePassword: "test-powersync-storage-password", - MeilisearchMasterKey: "test-meilisearch-master-key", + JWT: "test-jwt", + Sequin: "test-sequin", + SequinPassword: "test-sequin-password", + SequinReplicationPassword: "test-sequin-replication-password", + PowersyncStoragePassword: "test-powersync-storage-password", + PowersyncReplicationPassword: "test-powersync-replication-password", + MeilisearchMasterKey: "test-meilisearch-master-key", } } diff --git a/internal/resources/jobs/cdc_permissions.go b/internal/resources/jobs/cdc_permissions.go index 943567d..4293683 100644 --- a/internal/resources/jobs/cdc_permissions.go +++ b/internal/resources/jobs/cdc_permissions.go @@ -105,13 +105,13 @@ EOSQL # Apply Powersync grants echo "Applying Powersync grants..." psql "$PGCONNSTR" <<'EOSQL' --- Grant powersync_storage role access to create its schema +-- Grant powersync_storage role access to create its schema (internal sync state) GRANT CREATE ON DATABASE supabase TO powersync_storage; --- Grant usage on public schema for replication reads -GRANT USAGE ON SCHEMA public TO powersync_storage; -GRANT SELECT ON ALL TABLES IN SCHEMA public TO powersync_storage; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT SELECT ON TABLES TO powersync_storage; +-- Grant powersync_replication role CDC read access to public schema +GRANT USAGE ON SCHEMA public TO powersync_replication; +GRANT SELECT ON ALL TABLES IN SCHEMA public TO powersync_replication; +ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT SELECT ON TABLES TO powersync_replication; EOSQL ` } diff --git a/internal/resources/secrets/secrets.go b/internal/resources/secrets/secrets.go index de238eb..7b288f8 100644 --- a/internal/resources/secrets/secrets.go +++ b/internal/resources/secrets/secrets.go @@ -212,19 +212,27 @@ func SequinSecretNames(project *supabasev1alpha1.SupabaseProject) (sequin, sequi func GeneratePowersyncSecrets(project *supabasev1alpha1.SupabaseProject) ([]*corev1.Secret, error) { var secrets []*corev1.Secret - // Powersync storage role password + // Powersync storage role password (for internal sync state tables) storagePassword, _, err := generateRoleSecret(project, "powersync-storage", "powersync_storage") if err != nil { return nil, fmt.Errorf("failed to generate powersync-storage password: %w", err) } secrets = append(secrets, storagePassword) + // Powersync replication role password (for CDC/WAL reading) + replicationPassword, _, err := generateRoleSecret(project, "powersync-replication", "powersync_replication") + if err != nil { + return nil, fmt.Errorf("failed to generate powersync-replication password: %w", err) + } + secrets = append(secrets, replicationPassword) + return secrets, nil } // PowersyncSecretNames returns the expected secret names for Powersync -func PowersyncSecretNames(project *supabasev1alpha1.SupabaseProject) (powersyncStoragePassword string) { - return project.Name + "-powersync-storage-password" +func PowersyncSecretNames(project *supabasev1alpha1.SupabaseProject) (powersyncStoragePassword, powersyncReplicationPassword string) { + return project.Name + "-powersync-storage-password", + project.Name + "-powersync-replication-password" } // GenerateMeilisearchSecrets generates Meilisearch-related secrets diff --git a/internal/resources/secrets/secrets_test.go b/internal/resources/secrets/secrets_test.go index 76a0868..03da603 100644 --- a/internal/resources/secrets/secrets_test.go +++ b/internal/resources/secrets/secrets_test.go @@ -45,9 +45,12 @@ func TestSequinSecretNames(t *testing.T) { func TestPowersyncSecretNames(t *testing.T) { project := newTestProject("default") - got := PowersyncSecretNames(project) - if got != "my-app-powersync-storage-password" { - t.Errorf("got %q, want %q", got, "my-app-powersync-storage-password") + storagePwd, replPwd := PowersyncSecretNames(project) + if storagePwd != "my-app-powersync-storage-password" { + t.Errorf("storagePwd = %q, want %q", storagePwd, "my-app-powersync-storage-password") + } + if replPwd != "my-app-powersync-replication-password" { + t.Errorf("replPwd = %q, want %q", replPwd, "my-app-powersync-replication-password") } } @@ -142,19 +145,26 @@ func TestGeneratePowersyncSecrets(t *testing.T) { t.Fatalf("GeneratePowersyncSecrets() error = %v", err) } - if len(secrets) != 1 { - t.Fatalf("expected 1 secret, got %d", len(secrets)) + if len(secrets) != 2 { + t.Fatalf("expected 2 secrets, got %d", len(secrets)) } - secret := secrets[0] - if secret.Name != "my-app-powersync-storage-password" { - t.Errorf("name = %q, want %q", secret.Name, "my-app-powersync-storage-password") + // Storage role secret + storage := secrets[0] + if storage.Name != "my-app-powersync-storage-password" { + t.Errorf("storage name = %q, want %q", storage.Name, "my-app-powersync-storage-password") + } + if storage.StringData["username"] != "powersync_storage" { + t.Errorf("storage username = %q, want %q", storage.StringData["username"], "powersync_storage") } - if secret.StringData["username"] != "powersync_storage" { - t.Errorf("username = %q, want %q", secret.StringData["username"], "powersync_storage") + + // Replication role secret + repl := secrets[1] + if repl.Name != "my-app-powersync-replication-password" { + t.Errorf("replication name = %q, want %q", repl.Name, "my-app-powersync-replication-password") } - if len(secret.StringData["password"]) == 0 { - t.Error("expected non-empty password") + if repl.StringData["username"] != "powersync_replication" { + t.Errorf("replication username = %q, want %q", repl.StringData["username"], "powersync_replication") } } From 5e0963098e8687c8f7d4fe1e6c0f19c048b52fc0 Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 22 Jul 2026 12:57:37 +0800 Subject: [PATCH 14/21] fix(kong): use one worker by default --- internal/resources/deployments/helpers.go | 2 +- internal/resources/deployments/kong.go | 1 + internal/resources/deployments/kong_test.go | 35 +++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 internal/resources/deployments/kong_test.go diff --git a/internal/resources/deployments/helpers.go b/internal/resources/deployments/helpers.go index 5941c58..8ae25d9 100644 --- a/internal/resources/deployments/helpers.go +++ b/internal/resources/deployments/helpers.go @@ -123,7 +123,7 @@ func DefaultKongResources() corev1.ResourceRequirements { corev1.ResourceCPU: resource.MustParse("50m"), }, Limits: corev1.ResourceList{ - corev1.ResourceMemory: resource.MustParse("2Gi"), + corev1.ResourceMemory: resource.MustParse("1Gi"), corev1.ResourceCPU: resource.MustParse("500m"), }, } diff --git a/internal/resources/deployments/kong.go b/internal/resources/deployments/kong.go index f4eecb8..5f3f539 100644 --- a/internal/resources/deployments/kong.go +++ b/internal/resources/deployments/kong.go @@ -65,6 +65,7 @@ func BuildKongDeployment(project *supabasev1alpha1.SupabaseProject, secretNames {Name: "KONG_DATABASE", Value: "off"}, {Name: "KONG_DECLARATIVE_CONFIG", Value: "/kong/config/kong.yml"}, {Name: "KONG_DNS_ORDER", Value: "LAST,A,CNAME"}, + {Name: "KONG_NGINX_WORKER_PROCESSES", Value: "1"}, {Name: "KONG_PLUGINS", Value: "request-transformer,cors,key-auth,acl,basic-auth"}, {Name: "KONG_NGINX_PROXY_PROXY_BUFFER_SIZE", Value: "160k"}, {Name: "KONG_NGINX_PROXY_PROXY_BUFFERS", Value: "64 160k"}, diff --git a/internal/resources/deployments/kong_test.go b/internal/resources/deployments/kong_test.go new file mode 100644 index 0000000..49003b5 --- /dev/null +++ b/internal/resources/deployments/kong_test.go @@ -0,0 +1,35 @@ +package deployments + +import ( + "testing" + + "k8s.io/apimachinery/pkg/api/resource" +) + +func TestBuildKongDeployment_DefaultWorkerProcesses(t *testing.T) { + project := newTestProject(testNamespace) + secretNames := newTestSecretNames() + + deployment := BuildKongDeployment(project, secretNames) + for _, env := range deployment.Spec.Template.Spec.Containers[0].Env { + if env.Name == "KONG_NGINX_WORKER_PROCESSES" { + if env.Value != "1" { + t.Fatalf("KONG_NGINX_WORKER_PROCESSES = %q, want 1", env.Value) + } + return + } + } + + t.Fatal("KONG_NGINX_WORKER_PROCESSES env var not found") +} + +func TestDefaultKongResources(t *testing.T) { + resources := DefaultKongResources() + + if resources.Requests.Memory().Cmp(resource.MustParse("512Mi")) != 0 { + t.Errorf("memory request = %s, want 512Mi", resources.Requests.Memory()) + } + if resources.Limits.Memory().Cmp(resource.MustParse("1Gi")) != 0 { + t.Errorf("memory limit = %s, want 1Gi", resources.Limits.Memory()) + } +} From 65e003181199bf24675cf5eee8a4ebbf0d5d581c Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 22 Jul 2026 14:16:59 +0800 Subject: [PATCH 15/21] refactor(powersync): focus operator and publish via GHCR --- .dockerignore | 1 + .forgejo/workflows/ci.yaml | 78 -- .forgejo/workflows/pr.yaml | 62 - .github/workflows/ci.yaml | 47 +- .github/workflows/pr.yaml | 4 +- .github/workflows/release.yaml | 51 +- Dockerfile | 4 +- README.md | 49 +- api/v1alpha1/supabaseproject_types.go | 181 +-- api/v1alpha1/zz_generated.deepcopy.go | 135 -- .../supabase.guion.dev_supabaseprojects.yaml | 370 +----- .../templates/clusterrole.yaml | 8 +- charts/cloudnative-supabase/values.yaml | 3 +- .../supabase.guion.dev_supabaseprojects.yaml | 370 +----- config/rbac/role.yaml | 2 +- .../supabase_v1alpha1_supabaseproject.yaml | 35 +- dagger/dagger.json | 7 - dagger/package.json | 12 - dagger/src/index.ts | 88 -- dagger/tsconfig.json | 13 - docs/cdc-search-quickstart.md | 278 ---- ...-07-sequin-powersync-meilisearch-design.md | 1184 ----------------- internal/controller/cnpg_roles_test.go | 45 + internal/controller/suite_test.go | 3 + .../controller/supabaseproject_controller.go | 330 +---- .../supabaseproject_controller_test.go | 21 +- internal/resources/cnpg/cluster.go | 31 +- internal/resources/cnpg/publication.go | 31 + internal/resources/cnpg/publication_test.go | 29 + internal/resources/configmaps/powersync.go | 59 +- .../resources/configmaps/powersync_test.go | 35 +- internal/resources/defaults/images.go | 16 +- internal/resources/deployments/helpers.go | 27 + internal/resources/deployments/meilisearch.go | 169 --- .../resources/deployments/meilisearch_test.go | 194 --- internal/resources/deployments/powersync.go | 81 +- .../resources/deployments/powersync_test.go | 54 +- internal/resources/deployments/redis.go | 208 --- internal/resources/deployments/redis_test.go | 159 --- internal/resources/deployments/sequin.go | 247 ---- internal/resources/deployments/sequin_test.go | 326 ----- .../deployments/test_helpers_test.go | 28 + internal/resources/jobs/cdc_permissions.go | 35 +- .../resources/jobs/cdc_permissions_test.go | 24 + internal/resources/secrets/secrets.go | 109 -- internal/resources/secrets/secrets_test.go | 210 +-- internal/resources/services/services.go | 34 - internal/resources/services/services_test.go | 127 +- 48 files changed, 624 insertions(+), 4990 deletions(-) delete mode 100644 .forgejo/workflows/ci.yaml delete mode 100644 .forgejo/workflows/pr.yaml delete mode 100644 dagger/dagger.json delete mode 100644 dagger/package.json delete mode 100644 dagger/src/index.ts delete mode 100644 dagger/tsconfig.json delete mode 100644 docs/cdc-search-quickstart.md delete mode 100644 docs/plans/2026-02-07-sequin-powersync-meilisearch-design.md create mode 100644 internal/controller/cnpg_roles_test.go create mode 100644 internal/resources/cnpg/publication.go create mode 100644 internal/resources/cnpg/publication_test.go delete mode 100644 internal/resources/deployments/meilisearch.go delete mode 100644 internal/resources/deployments/meilisearch_test.go delete mode 100644 internal/resources/deployments/redis.go delete mode 100644 internal/resources/deployments/redis_test.go delete mode 100644 internal/resources/deployments/sequin.go delete mode 100644 internal/resources/deployments/sequin_test.go create mode 100644 internal/resources/deployments/test_helpers_test.go create mode 100644 internal/resources/jobs/cdc_permissions_test.go diff --git a/.dockerignore b/.dockerignore index 9af8280..9dd7bdb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,7 @@ ** # Re-include Go source files (but not *_test.go) +!**/ !**/*.go **/*_test.go diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml deleted file mode 100644 index 7035772..0000000 --- a/.forgejo/workflows/ci.yaml +++ /dev/null @@ -1,78 +0,0 @@ -name: CI - -on: - push: - branches: - - main - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: https://github.com/actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Download dependencies - run: go mod download - - - name: Unit tests - run: go test ./pkg/... ./internal/resources/... -v -count=1 - - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: https://github.com/actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - uses: https://github.com/golangci/golangci-lint-action@v9 - with: - version: v2.6 - args: --timeout=5m - - build-and-push: - runs-on: ubuntu-latest - needs: [test, lint] - steps: - - uses: actions/checkout@v4 - - - uses: https://github.com/actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Get short SHA - id: sha - run: echo "short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT - - - name: Publish container (SHA tag) - uses: https://github.com/dagger/dagger-for-github@v8.2.0 - env: - _EXPERIMENTAL_DAGGER_RUNNER_HOST: tcp://dagger-engine-0.dagger-headless.arc-systems.svc:8080 - REGISTRY: registry-docker-registry.arc-systems.svc:5000 - IMAGE_NAME: cloudnative-supabase - IMAGE_TAG: sha-${{ steps.sha.outputs.short }} - with: - version: "latest" - verb: call - workdir: dagger - args: publish --source=.. --registry=$REGISTRY --image=$IMAGE_NAME --tag=$IMAGE_TAG - - - name: Publish container (latest tag) - uses: https://github.com/dagger/dagger-for-github@v8.2.0 - env: - _EXPERIMENTAL_DAGGER_RUNNER_HOST: tcp://dagger-engine-0.dagger-headless.arc-systems.svc:8080 - REGISTRY: registry-docker-registry.arc-systems.svc:5000 - IMAGE_NAME: cloudnative-supabase - with: - version: "latest" - verb: call - workdir: dagger - args: publish --source=.. --registry=$REGISTRY --image=$IMAGE_NAME --tag=latest diff --git a/.forgejo/workflows/pr.yaml b/.forgejo/workflows/pr.yaml deleted file mode 100644 index 6d43775..0000000 --- a/.forgejo/workflows/pr.yaml +++ /dev/null @@ -1,62 +0,0 @@ -name: PR - -on: - pull_request: - branches: - - main - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: https://github.com/actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - uses: https://github.com/golangci/golangci-lint-action@v9 - with: - version: v2.6 - args: --timeout=5m - - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: https://github.com/actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Download dependencies - run: go mod download - - - name: Unit tests - run: go test ./pkg/... ./internal/resources/... -v -count=1 - - - name: Build - run: go build -o bin/manager cmd/main.go - - manifests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: https://github.com/actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Generate manifests - run: make generate manifests - - - name: Check for uncommitted changes - run: | - if [ -n "$(git status --porcelain)" ]; then - echo "Generated files are out of date. Run 'make generate manifests' and commit." - git status --porcelain - exit 1 - fi diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fefaf96..45ad1a0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -5,11 +5,15 @@ on: branches: - main +permissions: + contents: read + +env: + IMAGE_NAME: ghcr.io/guionai/cloudnative-supabase + jobs: build: runs-on: ubuntu-latest - permissions: - contents: write steps: - name: Checkout uses: actions/checkout@v4 @@ -24,7 +28,7 @@ jobs: run: go mod download - name: Run tests - run: go test ./pkg/... -v + run: go test ./... - name: Build run: go build -o bin/manager cmd/main.go @@ -32,22 +36,10 @@ jobs: - name: Generate manifests run: make generate manifests - - name: Sync CRDs to Helm chart + - name: Check generated files run: | cp config/crd/bases/*.yaml charts/cloudnative-supabase/crds/ - - if [ -z "$(git status --porcelain charts/cloudnative-supabase/crds/)" ]; then - echo "Helm chart CRDs already in sync" - exit 0 - fi - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - git add charts/cloudnative-supabase/crds/ - git commit -m "chore(chart): sync CRDs from generated manifests" - git pull --rebase origin main - git push origin main + git diff --exit-code lint: runs-on: ubuntu-latest @@ -64,12 +56,15 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: v2.6 + version: v2.5.0 args: --timeout=5m docker: runs-on: ubuntu-latest needs: [build, lint] + permissions: + contents: read + packages: write steps: - name: Checkout uses: actions/checkout@v4 @@ -84,14 +79,22 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest + type=sha,format=long + - name: Build and push uses: docker/build-push-action@v6 with: context: . push: true - platforms: linux/amd64 - tags: | - ghcr.io/guionai/cloudnative-supabase:latest - ghcr.io/guionai/cloudnative-supabase:${{ github.sha }} + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 67f829e..ba23dc3 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -22,7 +22,7 @@ jobs: run: go mod download - name: Run tests - run: go test ./pkg/... -v + run: go test ./... - name: Build run: go build -o bin/manager cmd/main.go @@ -53,5 +53,5 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: v2.6 + version: v2.5.0 args: --timeout=5m diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8561f84..121a52d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -8,6 +8,7 @@ on: env: REGISTRY: ghcr.io IMAGE_NAME: guionai/cloudnative-supabase + CHART_REGISTRY: oci://ghcr.io/guionai/charts jobs: release: @@ -26,7 +27,13 @@ jobs: cache: true - name: Run tests - run: go test ./pkg/... -v + run: go test ./... + + - name: Generate and check manifests + run: | + make generate manifests + cp config/crd/bases/*.yaml charts/cloudnative-supabase/crds/ + git diff --exit-code - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -53,43 +60,27 @@ jobs: with: context: . push: true - platforms: linux/amd64 + platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + - name: Set up Helm + uses: azure/setup-helm@v4 + + - name: Package and push Helm chart + run: | + VERSION="${GITHUB_REF_NAME#v}" + echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username "${{ github.actor }}" --password-stdin + mkdir -p dist + helm package charts/cloudnative-supabase --version "${VERSION}" --app-version "${VERSION}" --destination dist + helm push "dist/cloudnative-supabase-${VERSION}.tgz" "${CHART_REGISTRY}" + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: generate_release_notes: true files: | config/crd/bases/*.yaml - - - name: Commit and push chart version - run: | - VERSION="${GITHUB_REF_NAME#v}" - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - # Fetch the latest main branch - git fetch origin main - - # Checkout main branch - git checkout main - - # Apply version updates on main - sed -i "s/^version: .*/version: ${VERSION}/" charts/cloudnative-supabase/Chart.yaml - sed -i "s/^appVersion: .*/appVersion: ${VERSION}/" charts/cloudnative-supabase/Chart.yaml - sed -i "s/^ tag: .*/ tag: \"${VERSION}\"/" charts/cloudnative-supabase/values.yaml - - git add charts/cloudnative-supabase/Chart.yaml charts/cloudnative-supabase/values.yaml - - # Only commit if there are staged changes - if git diff --cached --quiet; then - echo "Chart already at correct version, skipping commit" - else - git commit -m "chore(chart): bump to ${VERSION}" - git push origin main - fi + dist/*.tgz diff --git a/Dockerfile b/Dockerfile index a022882..7b14304 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build the manager binary -FROM golang:1.25 AS builder +FROM --platform=$BUILDPLATFORM golang:1.25 AS builder ARG TARGETOS ARG TARGETARCH @@ -19,7 +19,7 @@ COPY . . # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. -RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager ./cmd # Use distroless as minimal base image to package the manager binary # Refer to https://github.com/GoogleContainerTools/distroless for more details diff --git a/README.md b/README.md index e6cd99e..7634b08 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ CloudNative Supabase provides a single `SupabaseProject` Custom Resource that ma - **Studio**: Supabase Studio dashboard - **Meta**: postgres-meta database introspection service - **Kong**: API gateway with declarative routing +- **PowerSync**: optional offline-first sync with edition 3 Sync Streams ## Features @@ -26,27 +27,28 @@ CloudNative Supabase provides a single `SupabaseProject` Custom Resource that ma ## Prerequisites -- Kubernetes v1.11.3+ +- A Kubernetes version supported by your CloudNativePG release - [CloudNativePG operator](https://cloudnative-pg.io/documentation/current/installation_upgrade/) installed - [CNPG Barman Cloud Plugin](https://github.com/cloudnative-pg/plugin-barman-cloud) (for backup/recovery features) -- kubectl v1.11.3+ +- Helm 3.8+ - (Optional) [Reloader](https://github.com/stakater/Reloader) - for automatic pod restarts on secret/configmap changes ## Installation -### Install CRDs +### Install with Helm ```bash -kubectl apply -f https://raw.githubusercontent.com/GuionAI/cloudnative-supabase/main/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml +helm install cloudnative-supabase \ + oci://ghcr.io/guionai/charts/cloudnative-supabase \ + --namespace cloudnative-supabase-system \ + --create-namespace \ + --version 0.1.8 ``` -### Deploy Operator +The public controller image is available at +`ghcr.io/guionai/cloudnative-supabase` and does not require registry credentials. -```bash -kubectl apply -f https://raw.githubusercontent.com/GuionAI/cloudnative-supabase/main/dist/install.yaml -``` - -Or using the Makefile: +### Install from source ```bash make deploy IMG=ghcr.io/guionai/cloudnative-supabase:latest @@ -168,6 +170,7 @@ Status conditions: - `StudioReady` - Studio is running - `MetaReady` - postgres-meta is running - `KongReady` - Kong gateway is running +- `PowersyncReady` - optional PowerSync service is running ## Configuration @@ -230,6 +233,30 @@ Status conditions: | `organizationName` | Organization name in UI | Default Organization | | `projectName` | Project name in UI | Default Project | +### PowerSync + +PowerSync is optional. Exactly one of `syncRules.inline` or +`syncRules.configMapRef` is required when it is enabled. The content must use +edition 3 Sync Streams; the operator does not install a broad default stream. + +```yaml +spec: + powersync: + api: + replicas: 1 + syncRules: + inline: | + config: + edition: 3 + streams: + notes: + auto_subscribe: true + query: SELECT id, title FROM notes WHERE user_id = auth.user_id() +``` + +The operator creates the two database roles, grants CDC access, creates the +`powersync` publication, and runs separate API and replication deployments. + ## Generated Secrets The operator auto-generates these secrets: @@ -240,6 +267,8 @@ The operator auto-generates these secrets: | `{name}-supabase-admin-password` | `username`, `password` | | `{name}-authenticator-password` | `username`, `password` | | `{name}-auth-admin-password` | `username`, `password` | +| `{name}-powersync-storage-password` | `username`, `password` | +| `{name}-powersync-replication-password` | `username`, `password` | To use an existing JWT secret, set `spec.jwt.secretRef`. diff --git a/api/v1alpha1/supabaseproject_types.go b/api/v1alpha1/supabaseproject_types.go index 28b7edf..0e7cab7 100644 --- a/api/v1alpha1/supabaseproject_types.go +++ b/api/v1alpha1/supabaseproject_types.go @@ -93,14 +93,8 @@ const ( // ConditionTypeCDCReady indicates CDC permissions have been applied ConditionTypeCDCReady = "CDCReady" - // ConditionTypeSequinReady indicates Sequin is ready - ConditionTypeSequinReady = "SequinReady" - // ConditionTypePowersyncReady indicates Powersync is ready ConditionTypePowersyncReady = "PowersyncReady" - - // ConditionTypeMeilisearchReady indicates Meilisearch is ready - ConditionTypeMeilisearchReady = "MeilisearchReady" ) // SupabaseProjectSpec defines the desired state of SupabaseProject @@ -139,18 +133,10 @@ type SupabaseProjectSpec struct { // +optional Kong KongSpec `json:"kong,omitempty"` - // Sequin CDC/event streaming configuration (optional - presence enables Sequin) - // +optional - Sequin *SequinSpec `json:"sequin,omitempty"` - // Powersync offline-first sync configuration (optional - presence enables Powersync) // +optional Powersync *PowersyncSpec `json:"powersync,omitempty"` - // Meilisearch full-text search configuration (optional - presence enables Meilisearch) - // +optional - Meilisearch *MeilisearchSpec `json:"meilisearch,omitempty"` - // ImagePullSecrets for all deployments // +optional ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"` @@ -197,7 +183,7 @@ type DatabaseSpec struct { // +optional Recovery *RecoverySpec `json:"recovery,omitempty"` - // AdditionalRoles beyond the standard Supabase roles (e.g., sequin_replication) + // AdditionalRoles beyond the roles managed by the operator // Uses CNPG RoleConfiguration directly for full compatibility // +optional AdditionalRoles []cnpgv1.RoleConfiguration `json:"additionalRoles,omitempty"` @@ -551,7 +537,7 @@ type ImageSpec struct { // +optional Registry string `json:"registry,omitempty"` - // Repository (e.g., sequin/sequin) + // Repository (e.g., journeyapps/powersync-service) // +optional Repository string `json:"repository,omitempty"` @@ -565,91 +551,9 @@ type ImageSpec struct { PullPolicy corev1.PullPolicy `json:"pullPolicy,omitempty"` } -// SequinSpec defines Sequin CDC/event streaming configuration -type SequinSpec struct { - // Image configuration (default: sequin/sequin:v0.13.25) - // +optional - Image ImageSpec `json:"image,omitempty"` - - // Replicas (default: 1) - // +kubebuilder:default=1 - // +optional - Replicas int32 `json:"replicas,omitempty"` - - // Resources for Sequin pods - // +optional - Resources corev1.ResourceRequirements `json:"resources,omitempty"` - - // Redis configuration - external reference required for Phase 1 - // +optional - Redis RedisSpec `json:"redis,omitempty"` - - // Account/user configuration - // +optional - Account *SequinAccountSpec `json:"account,omitempty"` -} - -// RedisSpec defines Redis configuration for Sequin. -// If External is nil, the operator deploys a bundled single-replica Redis StatefulSet. -type RedisSpec struct { - // External Redis reference. If nil, operator deploys bundled Redis. - // +optional - External *ExternalRedisSpec `json:"external,omitempty"` - - // Resources for bundled Redis (default: 128Mi/50m request, 256Mi/200m limit) - // Ignored when External is set. - // +optional - Resources corev1.ResourceRequirements `json:"resources,omitempty"` - - // Storage for bundled Redis persistence (default: 2Gi) - // Ignored when External is set. - // +optional - Storage RedisPersistenceSpec `json:"storage,omitempty"` -} - -// ExternalRedisSpec defines connection to an external Redis instance -type ExternalRedisSpec struct { - // Host of external Redis instance - // +required - Host string `json:"host"` - - // Port (default: 6379) - // +kubebuilder:default=6379 - // +optional - Port int32 `json:"port,omitempty"` - - // PasswordSecretRef for Redis AUTH (optional) - // +optional - PasswordSecretRef string `json:"passwordSecretRef,omitempty"` -} - -// RedisPersistenceSpec defines Redis persistent storage configuration -type RedisPersistenceSpec struct { - // StorageClass (default: "" = cluster default) - // +optional - StorageClass string `json:"storageClass,omitempty"` - - // Size (default: 2Gi) - // +kubebuilder:default="2Gi" - // +optional - Size string `json:"size,omitempty"` -} - -// SequinAccountSpec defines Sequin account/user configuration -type SequinAccountSpec struct { - // Account name (default: "default") - // +kubebuilder:default="default" - // +optional - Name string `json:"name,omitempty"` - - // Admin user email (default: "admin@example.com") - // +optional - Email string `json:"email,omitempty"` -} - // PowersyncSpec defines Powersync offline-first sync configuration type PowersyncSpec struct { - // Image configuration (default: journeyapps/powersync-service:1.18.2) + // Image configuration (default: journeyapps/powersync-service:1.20.4) // +optional Image ImageSpec `json:"image,omitempty"` @@ -661,9 +565,9 @@ type PowersyncSpec struct { // +optional Replication PowersyncReplicationSpec `json:"replication,omitempty"` - // Sync rules configuration - // +optional - SyncRules SyncRulesSpec `json:"syncRules,omitempty"` + // Sync Streams configuration. Exactly one of inline or configMapRef is required. + // +required + SyncRules SyncRulesSpec `json:"syncRules"` // Compact CronJob configuration // +optional @@ -672,8 +576,8 @@ type PowersyncSpec struct { // PowersyncAPISpec defines Powersync API deployment configuration type PowersyncAPISpec struct { - // Replicas (default: 2) - // +kubebuilder:default=2 + // Replicas (default: 1) + // +kubebuilder:default=1 // +optional Replicas int32 `json:"replicas,omitempty"` @@ -681,7 +585,7 @@ type PowersyncAPISpec struct { // +optional Resources corev1.ResourceRequirements `json:"resources,omitempty"` - // NodeOptions for heap size (default: "--max-old-space-size=330") + // NodeOptions for heap size (default: "--max-old-space-size=150") // +optional NodeOptions string `json:"nodeOptions,omitempty"` } @@ -692,18 +596,21 @@ type PowersyncReplicationSpec struct { // +optional Resources corev1.ResourceRequirements `json:"resources,omitempty"` - // NodeOptions for heap size (default: "--max-old-space-size=482") + // NodeOptions for heap size (default: "--max-old-space-size=230") // +optional NodeOptions string `json:"nodeOptions,omitempty"` } -// SyncRulesSpec defines sync rules configuration for Powersync +// SyncRulesSpec defines the edition 3 Sync Streams configuration for Powersync. +// +kubebuilder:validation:XValidation:rule="has(self.inline) != has(self.configMapRef)",message="exactly one of inline or configMapRef is required" type SyncRulesSpec struct { - // Inline sync rules (YAML string) + // Inline Sync Streams YAML, including config.edition: 3. + // +kubebuilder:validation:MinLength=1 // +optional Inline string `json:"inline,omitempty"` - // Reference to external ConfigMap containing sync rules (takes precedence over Inline) + // Reference to an external ConfigMap containing sync_rules.yaml. + // +kubebuilder:validation:MinLength=1 // +optional ConfigMapRef string `json:"configMapRef,omitempty"` } @@ -725,42 +632,6 @@ type PowersyncCompactSpec struct { Resources corev1.ResourceRequirements `json:"resources,omitempty"` } -// MeilisearchSpec defines Meilisearch full-text search configuration -type MeilisearchSpec struct { - // Image configuration (default: getmeili/meilisearch:v1.11.0) - // +optional - Image ImageSpec `json:"image,omitempty"` - - // Replicas (default: 1) - // +kubebuilder:default=1 - // +optional - Replicas int32 `json:"replicas,omitempty"` - - // Resources for Meilisearch pods - // +optional - Resources corev1.ResourceRequirements `json:"resources,omitempty"` - - // Persistence configuration - // +optional - Persistence PersistenceSpec `json:"persistence,omitempty"` - - // MasterKeySecretRef for existing secret (optional, auto-generated if not provided) - // +optional - MasterKeySecretRef string `json:"masterKeySecretRef,omitempty"` -} - -// PersistenceSpec defines persistent storage configuration -type PersistenceSpec struct { - // StorageClass (default: "" = cluster default) - // +optional - StorageClass string `json:"storageClass,omitempty"` - - // Size (default: 10Gi) - // +kubebuilder:default="10Gi" - // +optional - Size string `json:"size,omitempty"` -} - // SupabaseProjectStatus defines the observed state of SupabaseProject type SupabaseProjectStatus struct { // Phase represents the current lifecycle phase @@ -826,13 +697,9 @@ type ServicesStatus struct { // +optional Kong ServiceStatus `json:"kong,omitempty"` // +optional - Sequin ServiceStatus `json:"sequin,omitempty"` - // +optional PowersyncAPI ServiceStatus `json:"powersyncApi,omitempty"` // +optional PowersyncReplication ServiceStatus `json:"powersyncReplication,omitempty"` - // +optional - Meilisearch ServiceStatus `json:"meilisearch,omitempty"` } // ServiceStatus defines individual service status @@ -863,18 +730,6 @@ type SecretNamesStatus struct { // +optional AuthAdmin string `json:"authAdmin,omitempty"` - // Sequin is the name of the Sequin secret (secretKeyBase, vaultKey, apiToken) - // +optional - Sequin string `json:"sequin,omitempty"` - - // SequinPassword is the name of the sequin database role password secret - // +optional - SequinPassword string `json:"sequinPassword,omitempty"` - - // SequinReplicationPassword is the name of the sequin_replication role password secret - // +optional - SequinReplicationPassword string `json:"sequinReplicationPassword,omitempty"` - // PowersyncStoragePassword is the name of the powersync_storage role password secret // +optional PowersyncStoragePassword string `json:"powersyncStoragePassword,omitempty"` @@ -882,10 +737,6 @@ type SecretNamesStatus struct { // PowersyncReplicationPassword is the name of the powersync_replication role password secret // +optional PowersyncReplicationPassword string `json:"powersyncReplicationPassword,omitempty"` - - // MeilisearchMasterKey is the name of the Meilisearch master key secret - // +optional - MeilisearchMasterKey string `json:"meilisearchMasterKey,omitempty"` } // EndpointsStatus contains service endpoints diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 93ce67e..9d6126b 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -205,21 +205,6 @@ func (in *EndpointsStatus) DeepCopy() *EndpointsStatus { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ExternalRedisSpec) DeepCopyInto(out *ExternalRedisSpec) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalRedisSpec. -func (in *ExternalRedisSpec) DeepCopy() *ExternalRedisSpec { - if in == nil { - return nil - } - out := new(ExternalRedisSpec) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GoogleProviderSpec) DeepCopyInto(out *GoogleProviderSpec) { *out = *in @@ -308,24 +293,6 @@ func (in *KongSpec) DeepCopy() *KongSpec { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MeilisearchSpec) DeepCopyInto(out *MeilisearchSpec) { - *out = *in - out.Image = in.Image - in.Resources.DeepCopyInto(&out.Resources) - out.Persistence = in.Persistence -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MeilisearchSpec. -func (in *MeilisearchSpec) DeepCopy() *MeilisearchSpec { - if in == nil { - return nil - } - out := new(MeilisearchSpec) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MetaSpec) DeepCopyInto(out *MetaSpec) { *out = *in @@ -342,21 +309,6 @@ func (in *MetaSpec) DeepCopy() *MetaSpec { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *PersistenceSpec) DeepCopyInto(out *PersistenceSpec) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PersistenceSpec. -func (in *PersistenceSpec) DeepCopy() *PersistenceSpec { - if in == nil { - return nil - } - out := new(PersistenceSpec) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PowersyncAPISpec) DeepCopyInto(out *PowersyncAPISpec) { *out = *in @@ -441,43 +393,6 @@ func (in *RecoverySpec) DeepCopy() *RecoverySpec { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RedisPersistenceSpec) DeepCopyInto(out *RedisPersistenceSpec) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisPersistenceSpec. -func (in *RedisPersistenceSpec) DeepCopy() *RedisPersistenceSpec { - if in == nil { - return nil - } - out := new(RedisPersistenceSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RedisSpec) DeepCopyInto(out *RedisSpec) { - *out = *in - if in.External != nil { - in, out := &in.External, &out.External - *out = new(ExternalRedisSpec) - **out = **in - } - in.Resources.DeepCopyInto(&out.Resources) - out.Storage = in.Storage -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisSpec. -func (in *RedisSpec) DeepCopy() *RedisSpec { - if in == nil { - return nil - } - out := new(RedisSpec) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RestSpec) DeepCopyInto(out *RestSpec) { *out = *in @@ -559,44 +474,6 @@ func (in *SecretsSpec) DeepCopy() *SecretsSpec { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *SequinAccountSpec) DeepCopyInto(out *SequinAccountSpec) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SequinAccountSpec. -func (in *SequinAccountSpec) DeepCopy() *SequinAccountSpec { - if in == nil { - return nil - } - out := new(SequinAccountSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *SequinSpec) DeepCopyInto(out *SequinSpec) { - *out = *in - out.Image = in.Image - in.Resources.DeepCopyInto(&out.Resources) - in.Redis.DeepCopyInto(&out.Redis) - if in.Account != nil { - in, out := &in.Account, &out.Account - *out = new(SequinAccountSpec) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SequinSpec. -func (in *SequinSpec) DeepCopy() *SequinSpec { - if in == nil { - return nil - } - out := new(SequinSpec) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServiceStatus) DeepCopyInto(out *ServiceStatus) { *out = *in @@ -620,10 +497,8 @@ func (in *ServicesStatus) DeepCopyInto(out *ServicesStatus) { out.Studio = in.Studio out.Meta = in.Meta out.Kong = in.Kong - out.Sequin = in.Sequin out.PowersyncAPI = in.PowersyncAPI out.PowersyncReplication = in.PowersyncReplication - out.Meilisearch = in.Meilisearch } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServicesStatus. @@ -730,21 +605,11 @@ func (in *SupabaseProjectSpec) DeepCopyInto(out *SupabaseProjectSpec) { in.Studio.DeepCopyInto(&out.Studio) in.Meta.DeepCopyInto(&out.Meta) in.Kong.DeepCopyInto(&out.Kong) - if in.Sequin != nil { - in, out := &in.Sequin, &out.Sequin - *out = new(SequinSpec) - (*in).DeepCopyInto(*out) - } if in.Powersync != nil { in, out := &in.Powersync, &out.Powersync *out = new(PowersyncSpec) (*in).DeepCopyInto(*out) } - if in.Meilisearch != nil { - in, out := &in.Meilisearch, &out.Meilisearch - *out = new(MeilisearchSpec) - (*in).DeepCopyInto(*out) - } if in.ImagePullSecrets != nil { in, out := &in.ImagePullSecrets, &out.ImagePullSecrets *out = make([]v1.LocalObjectReference, len(*in)) diff --git a/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml b/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml index 4cf1efb..9e1db23 100644 --- a/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml +++ b/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml @@ -229,7 +229,7 @@ spec: type: array additionalRoles: description: |- - AdditionalRoles beyond the standard Supabase roles (e.g., sequin_replication) + AdditionalRoles beyond the roles managed by the operator Uses CNPG RoleConfiguration directly for full compatibility items: description: |- @@ -878,107 +878,6 @@ spec: type: object type: object type: object - meilisearch: - description: Meilisearch full-text search configuration (optional - - presence enables Meilisearch) - properties: - image: - description: 'Image configuration (default: getmeili/meilisearch:v1.11.0)' - properties: - pullPolicy: - default: IfNotPresent - description: 'PullPolicy (default: IfNotPresent)' - type: string - registry: - description: 'Registry (default: docker.io)' - type: string - repository: - description: Repository (e.g., sequin/sequin) - type: string - tag: - description: Tag (pinned stable version per service) - type: string - type: object - masterKeySecretRef: - description: MasterKeySecretRef for existing secret (optional, - auto-generated if not provided) - type: string - persistence: - description: Persistence configuration - properties: - size: - default: 10Gi - description: 'Size (default: 10Gi)' - type: string - storageClass: - description: 'StorageClass (default: "" = cluster default)' - type: string - type: object - replicas: - default: 1 - description: 'Replicas (default: 1)' - format: int32 - type: integer - resources: - description: Resources for Meilisearch pods - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This field depends on the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - request: - description: |- - Request is the name chosen for a request in the referenced claim. - If empty, everything from the claim is made available, otherwise - only the result of this request. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - type: object meta: description: Meta service configuration (postgres-meta) properties: @@ -1059,11 +958,11 @@ spec: description: API deployment configuration (client-facing) properties: nodeOptions: - description: 'NodeOptions for heap size (default: "--max-old-space-size=330")' + description: 'NodeOptions for heap size (default: "--max-old-space-size=150")' type: string replicas: - default: 2 - description: 'Replicas (default: 2)' + default: 1 + description: 'Replicas (default: 1)' format: int32 type: integer resources: @@ -1199,7 +1098,7 @@ spec: type: string type: object image: - description: 'Image configuration (default: journeyapps/powersync-service:1.18.2)' + description: 'Image configuration (default: journeyapps/powersync-service:1.20.4)' properties: pullPolicy: default: IfNotPresent @@ -1209,7 +1108,7 @@ spec: description: 'Registry (default: docker.io)' type: string repository: - description: Repository (e.g., sequin/sequin) + description: Repository (e.g., journeyapps/powersync-service) type: string tag: description: Tag (pinned stable version per service) @@ -1219,7 +1118,7 @@ spec: description: Replication deployment configuration (CDC processing) properties: nodeOptions: - description: 'NodeOptions for heap size (default: "--max-old-space-size=482")' + description: 'NodeOptions for heap size (default: "--max-old-space-size=230")' type: string resources: description: Resources for Powersync replication pods @@ -1282,16 +1181,25 @@ spec: type: object type: object syncRules: - description: Sync rules configuration + description: Sync Streams configuration. Exactly one of inline + or configMapRef is required. properties: configMapRef: - description: Reference to external ConfigMap containing sync - rules (takes precedence over Inline) + description: Reference to an external ConfigMap containing + sync_rules.yaml. + minLength: 1 type: string inline: - description: Inline sync rules (YAML string) + description: 'Inline Sync Streams YAML, including config.edition: + 3.' + minLength: 1 type: string type: object + x-kubernetes-validations: + - message: exactly one of inline or configMapRef is required + rule: has(self.inline) != has(self.configMapRef) + required: + - syncRules type: object rest: description: Rest service configuration (PostgREST) @@ -1414,200 +1322,6 @@ spec: rule: self.autoGenerate || (self.jwt.size() > 0 && self.supabaseAdmin.size() > 0 && self.authenticator.size() > 0 && self.authAdmin.size() > 0) - sequin: - description: Sequin CDC/event streaming configuration (optional - - presence enables Sequin) - properties: - account: - description: Account/user configuration - properties: - email: - description: 'Admin user email (default: "admin@example.com")' - type: string - name: - default: default - description: 'Account name (default: "default")' - type: string - type: object - image: - description: 'Image configuration (default: sequin/sequin:v0.13.25)' - properties: - pullPolicy: - default: IfNotPresent - description: 'PullPolicy (default: IfNotPresent)' - type: string - registry: - description: 'Registry (default: docker.io)' - type: string - repository: - description: Repository (e.g., sequin/sequin) - type: string - tag: - description: Tag (pinned stable version per service) - type: string - type: object - redis: - description: Redis configuration - external reference required - for Phase 1 - properties: - external: - description: External Redis reference. If nil, operator deploys - bundled Redis. - properties: - host: - description: Host of external Redis instance - type: string - passwordSecretRef: - description: PasswordSecretRef for Redis AUTH (optional) - type: string - port: - default: 6379 - description: 'Port (default: 6379)' - format: int32 - type: integer - required: - - host - type: object - resources: - description: |- - Resources for bundled Redis (default: 128Mi/50m request, 256Mi/200m limit) - Ignored when External is set. - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This field depends on the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - request: - description: |- - Request is the name chosen for a request in the referenced claim. - If empty, everything from the claim is made available, otherwise - only the result of this request. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - storage: - description: |- - Storage for bundled Redis persistence (default: 2Gi) - Ignored when External is set. - properties: - size: - default: 2Gi - description: 'Size (default: 2Gi)' - type: string - storageClass: - description: 'StorageClass (default: "" = cluster default)' - type: string - type: object - type: object - replicas: - default: 1 - description: 'Replicas (default: 1)' - format: int32 - type: integer - resources: - description: Resources for Sequin pods - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This field depends on the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - request: - description: |- - Request is the name chosen for a request in the referenced claim. - If empty, everything from the claim is made available, otherwise - only the result of this request. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - type: object studio: description: Studio dashboard configuration properties: @@ -1814,10 +1528,6 @@ spec: jwt: description: JWT is the name of the JWT secret type: string - meilisearchMasterKey: - description: MeilisearchMasterKey is the name of the Meilisearch - master key secret - type: string powersyncReplicationPassword: description: PowersyncReplicationPassword is the name of the powersync_replication role password secret @@ -1826,18 +1536,6 @@ spec: description: PowersyncStoragePassword is the name of the powersync_storage role password secret type: string - sequin: - description: Sequin is the name of the Sequin secret (secretKeyBase, - vaultKey, apiToken) - type: string - sequinPassword: - description: SequinPassword is the name of the sequin database - role password secret - type: string - sequinReplicationPassword: - description: SequinReplicationPassword is the name of the sequin_replication - role password secret - type: string supabaseAdmin: description: SupabaseAdmin is the name of the supabase_admin password secret @@ -1874,20 +1572,6 @@ spec: required: - ready type: object - meilisearch: - description: ServiceStatus defines individual service status - properties: - availableReplicas: - description: AvailableReplicas is the number of available - replicas - format: int32 - type: integer - ready: - description: Ready indicates if the service is ready - type: boolean - required: - - ready - type: object meta: description: ServiceStatus defines individual service status properties: @@ -1944,20 +1628,6 @@ spec: required: - ready type: object - sequin: - description: ServiceStatus defines individual service status - properties: - availableReplicas: - description: AvailableReplicas is the number of available - replicas - format: int32 - type: integer - ready: - description: Ready indicates if the service is ready - type: boolean - required: - - ready - type: object studio: description: ServiceStatus defines individual service status properties: diff --git a/charts/cloudnative-supabase/templates/clusterrole.yaml b/charts/cloudnative-supabase/templates/clusterrole.yaml index 46ce0f4..2eb2a4e 100644 --- a/charts/cloudnative-supabase/templates/clusterrole.yaml +++ b/charts/cloudnative-supabase/templates/clusterrole.yaml @@ -20,12 +20,11 @@ rules: - patch - update - watch - # Deployments and StatefulSets + # Deployments - apiGroups: - apps resources: - deployments - - statefulsets verbs: - create - delete @@ -34,7 +33,7 @@ rules: - patch - update - watch - # Jobs and CronJobs (CDC permissions, Powersync compact) + # Jobs and CronJobs (PowerSync permissions and compaction) - apiGroups: - batch resources: @@ -48,11 +47,12 @@ rules: - patch - update - watch - # CNPG Clusters and ScheduledBackups + # CNPG Clusters, ScheduledBackups, and Publications - apiGroups: - postgresql.cnpg.io resources: - clusters + - publications - scheduledbackups verbs: - create diff --git a/charts/cloudnative-supabase/values.yaml b/charts/cloudnative-supabase/values.yaml index f3d7ffd..34bdaf5 100644 --- a/charts/cloudnative-supabase/values.yaml +++ b/charts/cloudnative-supabase/values.yaml @@ -2,7 +2,8 @@ replicaCount: 1 image: repository: ghcr.io/guionai/cloudnative-supabase - tag: "0.1.8" + # Defaults to Chart.appVersion so OCI chart releases select the matching image. + tag: "" pullPolicy: IfNotPresent imagePullSecrets: [] diff --git a/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml b/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml index 4cf1efb..9e1db23 100644 --- a/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml +++ b/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml @@ -229,7 +229,7 @@ spec: type: array additionalRoles: description: |- - AdditionalRoles beyond the standard Supabase roles (e.g., sequin_replication) + AdditionalRoles beyond the roles managed by the operator Uses CNPG RoleConfiguration directly for full compatibility items: description: |- @@ -878,107 +878,6 @@ spec: type: object type: object type: object - meilisearch: - description: Meilisearch full-text search configuration (optional - - presence enables Meilisearch) - properties: - image: - description: 'Image configuration (default: getmeili/meilisearch:v1.11.0)' - properties: - pullPolicy: - default: IfNotPresent - description: 'PullPolicy (default: IfNotPresent)' - type: string - registry: - description: 'Registry (default: docker.io)' - type: string - repository: - description: Repository (e.g., sequin/sequin) - type: string - tag: - description: Tag (pinned stable version per service) - type: string - type: object - masterKeySecretRef: - description: MasterKeySecretRef for existing secret (optional, - auto-generated if not provided) - type: string - persistence: - description: Persistence configuration - properties: - size: - default: 10Gi - description: 'Size (default: 10Gi)' - type: string - storageClass: - description: 'StorageClass (default: "" = cluster default)' - type: string - type: object - replicas: - default: 1 - description: 'Replicas (default: 1)' - format: int32 - type: integer - resources: - description: Resources for Meilisearch pods - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This field depends on the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - request: - description: |- - Request is the name chosen for a request in the referenced claim. - If empty, everything from the claim is made available, otherwise - only the result of this request. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - type: object meta: description: Meta service configuration (postgres-meta) properties: @@ -1059,11 +958,11 @@ spec: description: API deployment configuration (client-facing) properties: nodeOptions: - description: 'NodeOptions for heap size (default: "--max-old-space-size=330")' + description: 'NodeOptions for heap size (default: "--max-old-space-size=150")' type: string replicas: - default: 2 - description: 'Replicas (default: 2)' + default: 1 + description: 'Replicas (default: 1)' format: int32 type: integer resources: @@ -1199,7 +1098,7 @@ spec: type: string type: object image: - description: 'Image configuration (default: journeyapps/powersync-service:1.18.2)' + description: 'Image configuration (default: journeyapps/powersync-service:1.20.4)' properties: pullPolicy: default: IfNotPresent @@ -1209,7 +1108,7 @@ spec: description: 'Registry (default: docker.io)' type: string repository: - description: Repository (e.g., sequin/sequin) + description: Repository (e.g., journeyapps/powersync-service) type: string tag: description: Tag (pinned stable version per service) @@ -1219,7 +1118,7 @@ spec: description: Replication deployment configuration (CDC processing) properties: nodeOptions: - description: 'NodeOptions for heap size (default: "--max-old-space-size=482")' + description: 'NodeOptions for heap size (default: "--max-old-space-size=230")' type: string resources: description: Resources for Powersync replication pods @@ -1282,16 +1181,25 @@ spec: type: object type: object syncRules: - description: Sync rules configuration + description: Sync Streams configuration. Exactly one of inline + or configMapRef is required. properties: configMapRef: - description: Reference to external ConfigMap containing sync - rules (takes precedence over Inline) + description: Reference to an external ConfigMap containing + sync_rules.yaml. + minLength: 1 type: string inline: - description: Inline sync rules (YAML string) + description: 'Inline Sync Streams YAML, including config.edition: + 3.' + minLength: 1 type: string type: object + x-kubernetes-validations: + - message: exactly one of inline or configMapRef is required + rule: has(self.inline) != has(self.configMapRef) + required: + - syncRules type: object rest: description: Rest service configuration (PostgREST) @@ -1414,200 +1322,6 @@ spec: rule: self.autoGenerate || (self.jwt.size() > 0 && self.supabaseAdmin.size() > 0 && self.authenticator.size() > 0 && self.authAdmin.size() > 0) - sequin: - description: Sequin CDC/event streaming configuration (optional - - presence enables Sequin) - properties: - account: - description: Account/user configuration - properties: - email: - description: 'Admin user email (default: "admin@example.com")' - type: string - name: - default: default - description: 'Account name (default: "default")' - type: string - type: object - image: - description: 'Image configuration (default: sequin/sequin:v0.13.25)' - properties: - pullPolicy: - default: IfNotPresent - description: 'PullPolicy (default: IfNotPresent)' - type: string - registry: - description: 'Registry (default: docker.io)' - type: string - repository: - description: Repository (e.g., sequin/sequin) - type: string - tag: - description: Tag (pinned stable version per service) - type: string - type: object - redis: - description: Redis configuration - external reference required - for Phase 1 - properties: - external: - description: External Redis reference. If nil, operator deploys - bundled Redis. - properties: - host: - description: Host of external Redis instance - type: string - passwordSecretRef: - description: PasswordSecretRef for Redis AUTH (optional) - type: string - port: - default: 6379 - description: 'Port (default: 6379)' - format: int32 - type: integer - required: - - host - type: object - resources: - description: |- - Resources for bundled Redis (default: 128Mi/50m request, 256Mi/200m limit) - Ignored when External is set. - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This field depends on the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - request: - description: |- - Request is the name chosen for a request in the referenced claim. - If empty, everything from the claim is made available, otherwise - only the result of this request. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - storage: - description: |- - Storage for bundled Redis persistence (default: 2Gi) - Ignored when External is set. - properties: - size: - default: 2Gi - description: 'Size (default: 2Gi)' - type: string - storageClass: - description: 'StorageClass (default: "" = cluster default)' - type: string - type: object - type: object - replicas: - default: 1 - description: 'Replicas (default: 1)' - format: int32 - type: integer - resources: - description: Resources for Sequin pods - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This field depends on the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - request: - description: |- - Request is the name chosen for a request in the referenced claim. - If empty, everything from the claim is made available, otherwise - only the result of this request. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - type: object studio: description: Studio dashboard configuration properties: @@ -1814,10 +1528,6 @@ spec: jwt: description: JWT is the name of the JWT secret type: string - meilisearchMasterKey: - description: MeilisearchMasterKey is the name of the Meilisearch - master key secret - type: string powersyncReplicationPassword: description: PowersyncReplicationPassword is the name of the powersync_replication role password secret @@ -1826,18 +1536,6 @@ spec: description: PowersyncStoragePassword is the name of the powersync_storage role password secret type: string - sequin: - description: Sequin is the name of the Sequin secret (secretKeyBase, - vaultKey, apiToken) - type: string - sequinPassword: - description: SequinPassword is the name of the sequin database - role password secret - type: string - sequinReplicationPassword: - description: SequinReplicationPassword is the name of the sequin_replication - role password secret - type: string supabaseAdmin: description: SupabaseAdmin is the name of the supabase_admin password secret @@ -1874,20 +1572,6 @@ spec: required: - ready type: object - meilisearch: - description: ServiceStatus defines individual service status - properties: - availableReplicas: - description: AvailableReplicas is the number of available - replicas - format: int32 - type: integer - ready: - description: Ready indicates if the service is ready - type: boolean - required: - - ready - type: object meta: description: ServiceStatus defines individual service status properties: @@ -1944,20 +1628,6 @@ spec: required: - ready type: object - sequin: - description: ServiceStatus defines individual service status - properties: - availableReplicas: - description: AvailableReplicas is the number of available - replicas - format: int32 - type: integer - ready: - description: Ready indicates if the service is ready - type: boolean - required: - - ready - type: object studio: description: ServiceStatus defines individual service status properties: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 285b500..86fd3d1 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -22,7 +22,6 @@ rules: - apps resources: - deployments - - statefulsets verbs: - create - delete @@ -60,6 +59,7 @@ rules: - postgresql.cnpg.io resources: - clusters + - publications - scheduledbackups verbs: - create diff --git a/config/samples/supabase_v1alpha1_supabaseproject.yaml b/config/samples/supabase_v1alpha1_supabaseproject.yaml index 81ac25a..05690b7 100644 --- a/config/samples/supabase_v1alpha1_supabaseproject.yaml +++ b/config/samples/supabase_v1alpha1_supabaseproject.yaml @@ -37,33 +37,16 @@ spec: # meta and kong use defaults (always enabled) - # --- Optional CDC/Search Services --- - # Uncomment sections below to enable. Presence = enabled, absence = disabled. - # All secrets, roles, and permissions are auto-generated. - - # Sequin CDC/event streaming (bundled Redis auto-deployed) - # sequin: {} - - # Sequin with external Redis - # sequin: - # replicas: 1 - # redis: - # external: - # host: redis.infra.svc - # port: 6379 - - # Powersync offline-first sync + # Optional PowerSync offline-first sync. Sync Streams are required and should + # select explicit columns guarded by auth.user_id(). # powersync: # api: - # replicas: 2 + # replicas: 1 # syncRules: # inline: | - # bucket_definitions: - # global: - # data: - # - SELECT * FROM public.* - - # Meilisearch full-text search - # meilisearch: - # persistence: - # size: 10Gi + # config: + # edition: 3 + # streams: + # notes: + # auto_subscribe: true + # query: SELECT id, title FROM notes WHERE user_id = auth.user_id() diff --git a/dagger/dagger.json b/dagger/dagger.json deleted file mode 100644 index 1836c6b..0000000 --- a/dagger/dagger.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "cloudnative-supabase", - "engineVersion": "v0.19.8", - "sdk": { - "source": "typescript" - } -} diff --git a/dagger/package.json b/dagger/package.json deleted file mode 100644 index 0f7c54f..0000000 --- a/dagger/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "cloudnative-supabase-dagger", - "private": true, - "type": "module", - "scripts": { - "develop": "dagger develop" - }, - "dependencies": { - "@dagger.io/dagger": "^0.19.8", - "typescript": "^5.8.0" - } -} diff --git a/dagger/src/index.ts b/dagger/src/index.ts deleted file mode 100644 index 916cdfa..0000000 --- a/dagger/src/index.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * CloudNative Supabase - Dagger module for building operator container image - * - * Builds the Go binary and packages it into a distroless container, - * matching the existing Dockerfile pattern. - */ -import { dag, Container, Directory, object, func } from "@dagger.io/dagger"; - -const GO_VERSION = "1.25"; -const DISTROLESS_IMAGE = "gcr.io/distroless/static:nonroot"; - -@object() -export class CloudnativeSupabase { - /** - * Build the operator binary and package into a distroless container - * - * @param source - Root directory of the Go project - */ - @func() - build(source: Directory): Container { - // Build stage: compile Go binary with caching - const builder = dag - .container() - .from(`golang:${GO_VERSION}`) - .withMountedDirectory("/workspace", source) - .withWorkdir("/workspace") - .withMountedCache("/go/pkg/mod", dag.cacheVolume("go-mod")) - .withMountedCache("/root/.cache/go-build", dag.cacheVolume("go-build")) - .withEnvVariable("CGO_ENABLED", "0") - .withEnvVariable("GOOS", "linux") - .withEnvVariable("GOARCH", "amd64") - .withExec(["go", "mod", "download"]) - .withExec(["go", "build", "-a", "-o", "manager", "cmd/main.go"]); - - // Runtime stage: distroless non-root - return dag - .container() - .from(DISTROLESS_IMAGE) - .withFile("/manager", builder.file("/workspace/manager")) - .withEntrypoint(["/manager"]) - .withUser("65532:65532"); - } - - /** - * Build and publish to a registry - * - * @param source - Root directory of the Go project - * @param registry - Registry URL (default: ttl.sh for testing) - * @param image - Image name - * @param tag - Image tag - */ - @func() - async publish( - source: Directory, - registry: string = "ttl.sh", - image: string = "cloudnative-supabase", - tag: string = "latest" - ): Promise { - const container = this.build(source); - const ref = `${registry}/${image}:${tag}`; - return container.publish(ref); - } - - /** - * Run unit tests - * - * @param source - Root directory of the Go project - */ - @func() - async test(source: Directory): Promise { - return dag - .container() - .from(`golang:${GO_VERSION}`) - .withMountedDirectory("/workspace", source) - .withWorkdir("/workspace") - .withMountedCache("/go/pkg/mod", dag.cacheVolume("go-mod")) - .withMountedCache("/root/.cache/go-build", dag.cacheVolume("go-build")) - .withExec([ - "go", - "test", - "./pkg/...", - "./internal/resources/...", - "-v", - "-count=1", - ]) - .stdout(); - } -} diff --git a/dagger/tsconfig.json b/dagger/tsconfig.json deleted file mode 100644 index c488ba4..0000000 --- a/dagger/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "moduleResolution": "Node", - "experimentalDecorators": true, - "module": "ES2022", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "outDir": "dist" - }, - "include": ["src/**/*.ts"] -} diff --git a/docs/cdc-search-quickstart.md b/docs/cdc-search-quickstart.md deleted file mode 100644 index 1af1576..0000000 --- a/docs/cdc-search-quickstart.md +++ /dev/null @@ -1,278 +0,0 @@ -# CDC & Search Services Quick Start - -This guide covers enabling optional CDC (Change Data Capture) and search services in your SupabaseProject. - -## Overview - -Three optional services can be enabled by adding their spec section to your SupabaseProject: - -| Service | Purpose | Trigger | -|---------|---------|---------| -| **Sequin** | CDC/event streaming | `spec.sequin` present | -| **Powersync** | Offline-first sync for mobile/web | `spec.powersync` present | -| **Meilisearch** | Full-text search engine | `spec.meilisearch` present | - -All services are optional. Omitting a section means that service is not deployed. - -## Minimal Setup - -Enable all three services with defaults: - -```yaml -apiVersion: supabase.guion.dev/v1alpha1 -kind: SupabaseProject -metadata: - name: my-app - namespace: my-app -spec: - database: - instances: 1 - storage: - size: 10Gi - - auth: - siteURL: https://app.example.com - externalURL: https://auth.example.com - - # CDC + Search - just add the section to enable - sequin: {} - powersync: {} - meilisearch: {} -``` - -This gives you: -- Sequin with bundled Redis (2Gi AOF persistence) -- Powersync with default sync rules and daily compaction -- Meilisearch with 10Gi storage - -All secrets, database roles, and CDC publications are auto-configured. - -## Sequin Configuration - -### With Bundled Redis (default) - -```yaml -sequin: {} -``` - -The operator deploys a single-replica Redis StatefulSet with: -- AOF persistence (`--appendonly yes`) -- 2Gi default storage -- Non-root security context - -### With External Redis - -```yaml -sequin: - redis: - external: - host: redis.infra.svc - port: 6379 -``` - -### Full Configuration - -```yaml -sequin: - image: - registry: ghcr.io - repository: guionai/sequin - tag: flicknote - replicas: 2 - resources: - requests: - memory: 512Mi - cpu: 200m - limits: - memory: 1Gi - cpu: 1 - redis: - external: - host: redis.infra.svc - port: 6379 -``` - -### Bundled Redis Customization - -```yaml -sequin: - redis: - resources: - requests: - memory: 256Mi - limits: - memory: 512Mi - storage: - size: 5Gi - storageClass: fast-ssd -``` - -## Powersync Configuration - -### Default Setup - -```yaml -powersync: {} -``` - -Creates: -- **API deployment** (1 replica) - client-facing sync endpoint -- **Replication deployment** (1 replica) - CDC processing -- **Compact CronJob** - daily at 3am -- **Default sync rules** - `SELECT * FROM public.*` - -### Custom Sync Rules (inline) - -```yaml -powersync: - syncRules: - inline: | - bucket_definitions: - user_data: - parameters: SELECT token_parameters.user_id as user_id - data: - - SELECT * FROM todos WHERE user_id = bucket.user_id -``` - -### External Sync Rules ConfigMap - -```yaml -powersync: - syncRules: - configMapRef: my-sync-rules -``` - -The referenced ConfigMap must have a `sync_rules.yaml` key. - -### Full Configuration - -```yaml -powersync: - image: - repository: journeyapps/powersync-service - tag: "1.18.2" - api: - replicas: 3 - resources: - requests: - memory: 512Mi - cpu: 200m - limits: - memory: 1Gi - cpu: 2 - nodeOptions: "--max-old-space-size=512" - replication: - resources: - requests: - memory: 1Gi - limits: - memory: 2Gi - nodeOptions: "--max-old-space-size=960" - compact: - enabled: true - schedule: "0 2 * * *" -``` - -## Meilisearch Configuration - -### Default Setup - -```yaml -meilisearch: {} -``` - -Creates a StatefulSet with 10Gi persistent storage and auto-generated master key. - -### With Existing Master Key - -```yaml -meilisearch: - masterKeySecretRef: my-meili-key -``` - -The secret must have a `masterKey` key. - -### Full Configuration - -```yaml -meilisearch: - image: - repository: getmeili/meilisearch - tag: v1.12.0 - replicas: 1 - persistence: - size: 50Gi - storageClass: longhorn - resources: - requests: - memory: 1Gi - cpu: 500m - limits: - memory: 4Gi - cpu: 2 -``` - -## Auto-Generated Resources - -### Secrets - -| Secret | Keys | Generated When | -|--------|------|----------------| -| `-sequin` | `secretKeyBase`, `vaultKey`, `apiToken` | `spec.sequin` present | -| `-sequin-password` | `username`, `password` | `spec.sequin` present | -| `-sequin-replication-password` | `username`, `password` | `spec.sequin` or `spec.powersync` present | -| `-powersync-storage-password` | `username`, `password` | `spec.powersync` present | -| `-meilisearch-master-key` | `masterKey` | `spec.meilisearch` present (unless `masterKeySecretRef` set) | - -Secrets are only generated if they don't already exist in the cluster, preventing regeneration on operator restart. - -### Database Roles - -| Role | Created When | Capabilities | -|------|-------------|-------------| -| `sequin` | `spec.sequin` present | Login, owns `sequin` database | -| `sequin_replication` | `spec.sequin` or `spec.powersync` | Login, replication, bypassrls | -| `powersync_storage` | `spec.powersync` present | Login | - -### CDC Permissions Job - -When Sequin or Powersync is enabled, a Kubernetes Job runs after the database is ready to grant CDC permissions: -- `USAGE` on `public` schema to `sequin_replication` -- `SELECT` on future tables in `public` schema -- `CREATE` on database for Sequin migrations - -## Status Conditions - -Monitor deployment progress via status conditions: - -```bash -kubectl get supabaseproject my-app -o jsonpath='{.status.conditions}' | jq . -``` - -| Condition | Description | -|-----------|-------------| -| `CDCReady` | CDC permissions applied | -| `SequinReady` | Sequin deployment available | -| `PowersyncReady` | Powersync deployments available | -| `MeilisearchReady` | Meilisearch StatefulSet ready | - -## Troubleshooting - -### Sequin not starting -Check that the bundled Redis or external Redis is reachable: -```bash -kubectl logs deploy/-sequin -``` - -### Powersync replication errors -Verify CDC permissions were applied: -```bash -kubectl get job -l app.kubernetes.io/component=cdc-permissions -kubectl logs job/-cdc-permissions -``` - -### Meilisearch data persistence -Meilisearch uses a StatefulSet with PVC. Data survives pod restarts. Check PVC status: -```bash -kubectl get pvc -l app.kubernetes.io/component=meilisearch -``` diff --git a/docs/plans/2026-02-07-sequin-powersync-meilisearch-design.md b/docs/plans/2026-02-07-sequin-powersync-meilisearch-design.md deleted file mode 100644 index c949e24..0000000 --- a/docs/plans/2026-02-07-sequin-powersync-meilisearch-design.md +++ /dev/null @@ -1,1184 +0,0 @@ -# Sequin + Powersync + Meilisearch Integration Design - -**Date:** 2026-02-07 -**Status:** Design Complete - Ready for Implementation -**Author:** Claude (via brainstorming session) - -## Overview - -Extend cloudnative-supabase operator to support three optional add-on services for complete stack deployment: - -1. **Sequin** - CDC/event streaming for real-time data pipelines -2. **Powersync** - Offline-first sync for mobile/web applications -3. **Meilisearch** - Fast full-text search engine - -**Goal:** Enable complete Supabase + CDC + Search stack deployment in minutes with minimal configuration. - -## Design Decisions Summary - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Integration pattern | Operator-managed via optional CRD specs | Single source of truth, proper initialization order | -| Optionality | Optional spec sections (presence = enabled) | Matches existing backup/recovery pattern | -| Redis dependency | Hybrid: external reference or auto-deployed | Flexibility for dev (bundled) vs prod (external) | -| CDC configuration | Fully automatic (roles, publications, schemas) | Minimal user config, reduces misconfiguration | -| Sync rules | Both inline and ConfigMap reference | Matches flicknote-deploy pattern | -| Image versions | Pinned stable defaults with full override | Production reliability, allows customization | -| Resource defaults | Production-ready from flicknote-deploy | Battle-tested values, overridable | -| Health checks | Standard K8s probes, individual conditions | Clear observability, standard patterns | -| Service exposure | ClusterIP only | Cloudflare Tunnel for external access | -| Monitoring | Expose metrics ports, no ServiceMonitors | Stack-agnostic (Prometheus, VictoriaMetrics, etc.) | -| Deployment order | After core Supabase services | Treats CDC as enhancement layer | -| Secret generation | Auto-generate all secrets | Zero-config deployment | - -## CRD API Structure - -### Top-level Spec Extensions - -```go -type SupabaseProjectSpec struct { - // ... existing fields (Database, Auth, Rest, Studio, Meta, Kong) ... - - // Sequin CDC/event streaming configuration (optional) - // +optional - Sequin *SequinSpec `json:"sequin,omitempty"` - - // Powersync offline-first sync configuration (optional) - // +optional - Powersync *PowersyncSpec `json:"powersync,omitempty"` - - // Meilisearch full-text search configuration (optional) - // +optional - Meilisearch *MeilisearchSpec `json:"meilisearch,omitempty"` -} -``` - -### SequinSpec - -```go -type SequinSpec struct { - // Image configuration (default: sequin/sequin:v0.13.25) - // +optional - Image ImageSpec `json:"image,omitempty"` - - // Replicas (default: 1) - // +kubebuilder:default=1 - // +optional - Replicas int32 `json:"replicas,omitempty"` - - // Resources (defaults: 256Mi/100m CPU → 512Mi/500m CPU) - // +optional - Resources corev1.ResourceRequirements `json:"resources,omitempty"` - - // Redis configuration - external or bundled - // +optional - Redis RedisSpec `json:"redis,omitempty"` - - // Account/user configuration (defaults provided) - // +optional - Account *SequinAccountSpec `json:"account,omitempty"` -} - -type RedisSpec struct { - // External Redis reference (if nil, operator deploys minimal Redis) - // +optional - External *ExternalRedisSpec `json:"external,omitempty"` - - // Resources for bundled Redis (default: 256Mi/50m → 256Mi/100m) - // +optional - Resources corev1.ResourceRequirements `json:"resources,omitempty"` - - // Persistence for bundled Redis - // +optional - Storage RedisPersistenceSpec `json:"storage,omitempty"` -} - -type ExternalRedisSpec struct { - // Host of external Redis instance - // +required - Host string `json:"host"` - - // Port (default: 6379) - // +kubebuilder:default=6379 - // +optional - Port int32 `json:"port,omitempty"` - - // PasswordSecretRef for Redis AUTH (optional) - // +optional - PasswordSecretRef string `json:"passwordSecretRef,omitempty"` -} - -type RedisPersistenceSpec struct { - // StorageClass (default: "" = cluster default) - // +optional - StorageClass string `json:"storageClass,omitempty"` - - // Size (default: 1Gi) - // +kubebuilder:default="1Gi" - // +optional - Size string `json:"size,omitempty"` -} - -type SequinAccountSpec struct { - // Account name (default: "default") - // +kubebuilder:default="default" - // +optional - Name string `json:"name,omitempty"` - - // Admin user email (default: "admin@example.com") - // +optional - Email string `json:"email,omitempty"` -} -``` - -### PowersyncSpec - -```go -type PowersyncSpec struct { - // Image configuration (default: journeyapps/powersync-service:1.18.2) - // +optional - Image ImageSpec `json:"image,omitempty"` - - // API deployment configuration (client-facing) - // +optional - API PowersyncAPISpec `json:"api,omitempty"` - - // Replication deployment configuration (CDC processing) - // +optional - Replication PowersyncReplicationSpec `json:"replication,omitempty"` - - // Sync rules configuration - // +optional - SyncRules SyncRulesSpec `json:"syncRules,omitempty"` - - // Compact CronJob configuration - // +optional - Compact PowersyncCompactSpec `json:"compact,omitempty"` -} - -type PowersyncAPISpec struct { - // Replicas (default: 2) - // +kubebuilder:default=2 - // +optional - Replicas int32 `json:"replicas,omitempty"` - - // Resources (default: 360Mi/100m → 360Mi/1cpu) - // +optional - Resources corev1.ResourceRequirements `json:"resources,omitempty"` - - // NodeOptions for heap size (default: "--max-old-space-size=330") - // +optional - NodeOptions string `json:"nodeOptions,omitempty"` -} - -type PowersyncReplicationSpec struct { - // Resources (default: 512Mi/100m → 512Mi/1cpu) - // +optional - Resources corev1.ResourceRequirements `json:"resources,omitempty"` - - // NodeOptions for heap size (default: "--max-old-space-size=482") - // +optional - NodeOptions string `json:"nodeOptions,omitempty"` -} - -type SyncRulesSpec struct { - // Inline sync rules (YAML string) - // If both Inline and ConfigMapRef are empty, uses default todolist example - // +optional - Inline string `json:"inline,omitempty"` - - // Reference to external ConfigMap containing sync rules - // Takes precedence over Inline if both provided - // +optional - ConfigMapRef string `json:"configMapRef,omitempty"` -} - -type PowersyncCompactSpec struct { - // Enabled (default: true) - // +kubebuilder:default=true - // +optional - Enabled bool `json:"enabled"` - - // Schedule in cron format (default: "0 3 * * *" = 3am daily) - // +kubebuilder:default="0 3 * * *" - // +optional - Schedule string `json:"schedule,omitempty"` - - // Resources (default: 256Mi/100m → 1Gi/500m) - // +optional - Resources corev1.ResourceRequirements `json:"resources,omitempty"` -} -``` - -### MeilisearchSpec - -```go -type MeilisearchSpec struct { - // Image configuration (default: getmeili/meilisearch:v1.11.0) - // +optional - Image ImageSpec `json:"image,omitempty"` - - // Replicas (default: 1) - // +kubebuilder:default=1 - // +optional - Replicas int32 `json:"replicas,omitempty"` - - // Resources (default: 512Mi/250m → 2Gi/500m) - // +optional - Resources corev1.ResourceRequirements `json:"resources,omitempty"` - - // Persistence configuration - // +optional - Persistence PersistenceSpec `json:"persistence,omitempty"` - - // MasterKeySecretRef for existing secret (optional) - // If not provided, operator auto-generates master key - // +optional - MasterKeySecretRef string `json:"masterKeySecretRef,omitempty"` -} - -type PersistenceSpec struct { - // StorageClass (default: "" = cluster default) - // +optional - StorageClass string `json:"storageClass,omitempty"` - - // Size (default: 10Gi) - // +kubebuilder:default="10Gi" - // +optional - Size string `json:"size,omitempty"` -} -``` - -### Common ImageSpec - -```go -// ImageSpec defines container image configuration -type ImageSpec struct { - // Registry (default: docker.io) - // +optional - Registry string `json:"registry,omitempty"` - - // Repository (e.g., sequin/sequin, guionai/sequin) - // +optional - Repository string `json:"repository,omitempty"` - - // Tag (pinned stable version per service) - // +optional - Tag string `json:"tag,omitempty"` - - // PullPolicy (default: IfNotPresent) - // +kubebuilder:default=IfNotPresent - // +optional - PullPolicy string `json:"pullPolicy,omitempty"` -} -``` - -## Database & CDC Auto-Configuration - -When `spec.sequin` or `spec.powersync` are present, the operator automatically configures CDC infrastructure. - -### Automatic CNPG Roles - -Added to `spec.database.additionalRoles` in the CNPG Cluster: - -```yaml -# When spec.sequin exists: -- name: sequin - ensure: present - login: true - passwordSecret: - name: -sequin-password - -- name: sequin_replication - ensure: present - login: true - replication: true - bypassrls: true - passwordSecret: - name: -sequin-replication-password - -# When spec.powersync exists: -- name: powersync_storage - ensure: present - login: true - passwordSecret: - name: -powersync-storage-password -``` - -### Automatic Database Resources - -```yaml -# Sequin gets its own database with citext extension -additionalDatabases: - - name: sequin - owner: sequin - extensions: - - name: citext - ensure: present - -# Powersync schema in main Supabase database -bootstrapDatabase: - schemas: - - name: powersync - owner: powersync_storage - -# CDC Publications for change data capture -publications: - - name: sequin_pub - publicationName: sequin_pub - database: supabase - target: - objects: - - tablesInSchema: public - - - name: powersync - database: supabase - target: - objects: - - tablesInSchema: public -``` - -### CDC Permissions SQL - -**Solution:** dbmate migration Job (mirrors flicknote-deploy pattern) - -The operator creates a Kubernetes Job that runs dbmate to apply CDC permissions after CNPG cluster is ready. - -**Migration file** (`20260207000001_cdc_grants.sql`): -```sql --- migrate:up - --- Grant CDC role (sequin_replication) read access to public schema only --- More restrictive than pg_read_all_data (avoids system schema access) - --- Grant schema usage -GRANT USAGE ON SCHEMA public TO sequin_replication; - --- Grant sequin CREATE ON DATABASE so its migrations can run CREATE SCHEMA IF NOT EXISTS --- (supabase_admin owns the database, so it can grant this directly) -GRANT CREATE ON DATABASE supabase TO sequin; - --- Grant SELECT on future tables created by supabase_admin in public schema --- (tables are created by subsequent migrations, so default privileges cover all) -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT SELECT ON TABLES TO sequin_replication; - --- migrate:down - -REVOKE USAGE ON SCHEMA public FROM sequin_replication; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE SELECT ON TABLES FROM sequin_replication; -REVOKE CREATE ON DATABASE supabase FROM sequin; -``` - -**Implementation:** -- ConfigMap containing migration file -- Job with dbmate image (`ghcr.io/amacneil/dbmate:2.24`) -- Uses `--migrations-table=cloudnative_supabase_schema_migrations` to avoid conflicts with application's schema_migrations table -- Runs `dbmate up` with `--no-dump-schema` flag -- InitContainer waits for CNPG cluster ready (checks for auth.users table existence) -- Status tracked via `ConditionTypeCDCReady` condition - -Reference: `/Users/neil/Code/guion/flick-backend-31/tanka/charts/db-init` - -### Secret Generation - -Operator auto-generates these secrets if not provided: - -| Secret Name | Keys | Purpose | -|-------------|------|---------| -| `-sequin` | `secretKeyBase`, `vaultKey`, `apiToken` | Sequin encryption + API auth | -| `-sequin-password` | `username`, `password` | Sequin database role | -| `-sequin-replication-password` | `username`, `password` | CDC replication role | -| `-powersync-storage-password` | `username`, `password` | Powersync storage role | -| `-meilisearch-master-key` | `masterKey` | Meilisearch admin auth | - -Secrets are only generated if they don't already exist (prevents regeneration on operator restart). - -## Deployment Resources - -### Sequin Resources (when `spec.sequin` present) - -1. **Secret**: `-sequin` - ```yaml - data: - secretKeyBase: # 64 bytes - vaultKey: # 32 bytes - apiToken: # API token for CLI - ``` - -2. **Deployment**: `-sequin` - ```yaml - spec: - replicas: 1 # from spec.sequin.replicas - template: - spec: - containers: - - name: sequin - image: sequin/sequin:v0.13.25 # or user override - env: - - name: DATABASE_URL - value: postgres://sequin:@:5432/sequin - - name: REDIS_URL - value: redis://:6379 - - name: SECRET_KEY_BASE - valueFrom: - secretKeyRef: - name: -sequin - key: secretKeyBase - # ... other env vars - resources: - requests: - memory: 256Mi - cpu: 100m - limits: - memory: 512Mi - cpu: 500m - ``` - -3. **Service**: `-sequin` - ```yaml - spec: - type: ClusterIP - ports: - - port: 7376 - name: http - - port: 4000 # metrics - name: metrics - ``` - -4. **Redis** (if `spec.sequin.redis.external` not provided): - - **StatefulSet**: `-sequin-redis` (1 replica) - - **Service**: `-sequin-redis` (ClusterIP, port 6379) - - **PVC**: `redis-data--sequin-redis-0` (1Gi) - -### Powersync Resources (when `spec.powersync` present) - -1. **ConfigMap**: `-powersync-config` - ```yaml - data: - config.json: | - { - "storage": { - "type": "postgresql", - "uri": "postgres://:5432/supabase", - "username": "powersync_storage", - "password": "" - }, - "replication": { - "connections": [{ - "type": "postgresql", - "uri": "postgres://:5432/supabase", - "username": "sequin_replication", - "password": "", - "tag": "default" - }] - }, - "client_auth": { - "supabase": true, - "supabase_jwt_secret": "", - "audience": ["authenticated"] - }, - "sync_rules": { - "path": "/powersync/sync_rules/sync_rules.yaml" - } - } - ``` - -2. **ConfigMap**: `-powersync-sync-rules` - ```yaml - data: - sync_rules.yaml: | - # From spec.powersync.syncRules.inline - # OR references external ConfigMap - # OR default todolist example: - bucket_definitions: - global: - data: - - select _id as id, * from lists - - select _id as id, * from todos - ``` - -3. **Deployment**: `-powersync-api` - ```yaml - spec: - replicas: 2 # from spec.powersync.api.replicas - template: - spec: - containers: - - name: powersync - image: journeyapps/powersync-service:1.18.2 - command: ["node", "dist/src/entry-api.js"] - env: - - name: NODE_OPTIONS - value: "--max-old-space-size=330" - - name: POWERSYNC_CONFIG_PATH - value: "/powersync/config/config.json" - volumeMounts: - - name: config - mountPath: /powersync/config - - name: sync-rules - mountPath: /powersync/sync_rules - resources: - requests: - memory: 360Mi - cpu: 100m - limits: - memory: 360Mi - cpu: 1 - ``` - -4. **Deployment**: `-powersync-replication` - - Same image, different command: `["node", "dist/src/entry-replication.js"]` - - Resources: 512Mi/100m → 512Mi/1cpu - - NODE_OPTIONS: `--max-old-space-size=482` - -5. **Service**: `-powersync` - ```yaml - spec: - type: ClusterIP - ports: - - port: 8080 - name: http - - port: 9464 - name: metrics - ``` - -6. **CronJob**: `-powersync-compact` - ```yaml - spec: - schedule: "0 3 * * *" # 3am daily - jobTemplate: - spec: - template: - spec: - containers: - - name: compact - image: journeyapps/powersync-service:1.18.2 - command: ["node", "dist/src/entry-compact.js"] - ``` - -### Meilisearch Resources (when `spec.meilisearch` present) - -1. **Secret**: `-meilisearch-master-key` - ```yaml - data: - masterKey: # 32 bytes - ``` - -2. **StatefulSet**: `-meilisearch` - ```yaml - spec: - replicas: 1 - volumeClaimTemplates: - - metadata: - name: data - spec: - storageClassName: "" # cluster default - resources: - requests: - storage: 10Gi - template: - spec: - containers: - - name: meilisearch - image: getmeili/meilisearch:v1.11.0 - env: - - name: MEILI_ENV - value: "production" - - name: MEILI_NO_ANALYTICS - value: "true" - - name: MEILI_EXPERIMENTAL_LOGS_MODE - value: "json" - - name: MEILI_MASTER_KEY - valueFrom: - secretKeyRef: - name: -meilisearch-master-key - key: masterKey - volumeMounts: - - name: data - mountPath: /meili_data - resources: - requests: - memory: 512Mi - cpu: 250m - limits: - memory: 2Gi - cpu: 500m - ``` - -3. **Service**: `-meilisearch` - ```yaml - spec: - type: ClusterIP - ports: - - port: 7700 - name: http - ``` - -### Owner References - -All resources have `ownerReferences` pointing to the SupabaseProject for automatic garbage collection on delete. - -## Status & Observability - -### New Status Conditions - -```go -const ( - // ... existing conditions (Ready, DatabaseReady, AuthReady, etc.) ... - - // Sequin conditions - ConditionTypeSequinReady = "SequinReady" - ConditionTypeSequinDatabaseReady = "SequinDatabaseReady" - - // Powersync conditions - ConditionTypePowersyncReady = "PowersyncReady" - ConditionTypePowersyncStorageReady = "PowersyncStorageReady" - - // Meilisearch condition - ConditionTypeMeilisearchReady = "MeilisearchReady" -) -``` - -### Extended ServicesStatus - -```go -type ServicesStatus struct { - // Existing services - Auth ServiceStatus `json:"auth,omitempty"` - Rest ServiceStatus `json:"rest,omitempty"` - Studio ServiceStatus `json:"studio,omitempty"` - Meta ServiceStatus `json:"meta,omitempty"` - Kong ServiceStatus `json:"kong,omitempty"` - - // New services - Sequin ServiceStatus `json:"sequin,omitempty"` - PowersyncAPI ServiceStatus `json:"powersyncApi,omitempty"` - PowersyncReplication ServiceStatus `json:"powersyncReplication,omitempty"` - Meilisearch ServiceStatus `json:"meilisearch,omitempty"` -} -``` - -### Health Checks - -All deployments use standard Kubernetes readiness/liveness probes: - -**Sequin:** -```yaml -livenessProbe: - httpGet: - path: /health - port: 7376 - initialDelaySeconds: 30 - periodSeconds: 10 -readinessProbe: - httpGet: - path: /health - port: 7376 - initialDelaySeconds: 10 - periodSeconds: 5 -``` - -**Powersync API/Replication:** -```yaml -livenessProbe: - httpGet: - path: /api/health - port: 8080 - initialDelaySeconds: 30 -readinessProbe: - httpGet: - path: /api/health - port: 8080 - initialDelaySeconds: 10 -``` - -**Meilisearch:** -```yaml -livenessProbe: - httpGet: - path: /health - port: 7700 - initialDelaySeconds: 30 -readinessProbe: - httpGet: - path: /health - port: 7700 - initialDelaySeconds: 10 -``` - -### Status Update Logic - -Operator watches Deployment/StatefulSet status and updates conditions: - -1. **SequinDatabaseReady**: True after Sequin database + roles created in CNPG -2. **SequinReady**: True when Sequin deployment has `availableReplicas >= 1` -3. **PowersyncStorageReady**: True after powersync schema + publications created -4. **PowersyncReady**: True when both API and Replication deployments have `availableReplicas >= 1` -5. **MeilisearchReady**: True when StatefulSet has `readyReplicas >= 1` - -**Overall Ready condition:** True when all enabled services are ready (including CDC/search if specs present). - -### Metrics Exposure - -All services expose Prometheus metrics on dedicated ports (no ServiceMonitor resources created): - -| Service | Metrics Port | Endpoint | -|---------|--------------|----------| -| Sequin | 4000 | `/metrics` | -| Powersync | 9464 | `/metrics` | -| Meilisearch | 7700 | `/metrics` | -| Redis (bundled) | 9121 | `/metrics` (via redis-exporter sidecar, optional) | - -Users add ServiceMonitor/PodMonitor resources based on their monitoring stack. - -## Reconciliation Flow - -### Updated Controller Logic - -```go -func (r *SupabaseProjectReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - project := &v1alpha1.SupabaseProject{} - // ... fetch project ... - - // Phase 1: Secrets - if err := r.reconcileSecrets(ctx, project); err != nil { - return ctrl.Result{}, err - } - // Generates: - // - JWT secret (existing) - // - Database passwords (existing) - // - Sequin secrets (if spec.sequin != nil) - // - Powersync passwords (if spec.powersync != nil) - // - Meilisearch master key (if spec.meilisearch != nil) - - // Phase 2: InitSQL ConfigMap - if err := r.reconcileInitSQL(ctx, project); err != nil { - return ctrl.Result{}, err - } - // Includes standard Supabase init SQL - // TODO: Add CDC permissions SQL (pending Task #1 research) - - // Phase 3: Backup/Recovery (if enabled) - if err := r.reconcileBackup(ctx, project); err != nil { - return ctrl.Result{}, err - } - - // Phase 4: CNPG Cluster - if err := r.reconcileCNPGCluster(ctx, project); err != nil { - return ctrl.Result{}, err - } - // Extends cluster with: - // - Sequin roles + database (if spec.sequin) - // - Powersync roles + schema (if spec.powersync) - // - Publications (if CDC enabled) - - // Phase 5: Wait for Database Ready - if !r.isDatabaseReady(ctx, project) { - r.setCondition(project, ConditionTypeDatabaseReady, metav1.ConditionFalse, "Waiting", "Database not ready") - return ctrl.Result{RequeueAfter: 10 * time.Second}, nil - } - r.setCondition(project, ConditionTypeDatabaseReady, metav1.ConditionTrue, "Ready", "Database ready") - - // Phase 6: Core Services - if err := r.reconcileCoreServices(ctx, project); err != nil { - return ctrl.Result{}, err - } - // Deploys Auth, REST, Studio, Meta, Kong - - // Phase 7: CDC Services (after core services - flicknote-deploy pattern) - if project.Spec.Sequin != nil { - if err := r.reconcileSequin(ctx, project); err != nil { - r.setCondition(project, ConditionTypeSequinReady, metav1.ConditionFalse, "Error", err.Error()) - return ctrl.Result{}, err - } - } - - if project.Spec.Powersync != nil { - if err := r.reconcilePowersync(ctx, project); err != nil { - r.setCondition(project, ConditionTypePowersyncReady, metav1.ConditionFalse, "Error", err.Error()) - return ctrl.Result{}, err - } - } - - // Phase 8: Search Service - if project.Spec.Meilisearch != nil { - if err := r.reconcileMeilisearch(ctx, project); err != nil { - r.setCondition(project, ConditionTypeMeilisearchReady, metav1.ConditionFalse, "Error", err.Error()) - return ctrl.Result{}, err - } - } - - // Phase 9: Update Overall Status - if err := r.updateStatus(ctx, project); err != nil { - return ctrl.Result{}, err - } - - return ctrl.Result{}, nil -} -``` - -### Phase 7 Detail: reconcileSequin - -```go -func (r *SupabaseProjectReconciler) reconcileSequin(ctx context.Context, project *v1alpha1.SupabaseProject) error { - // 1. Deploy Redis (if external not configured) - if project.Spec.Sequin.Redis.External == nil { - if err := r.reconcileSequinRedis(ctx, project); err != nil { - return fmt.Errorf("failed to deploy Redis: %w", err) - } - } - - // 2. Deploy Sequin deployment - deployment := r.buildSequinDeployment(project) - if err := r.createOrUpdate(ctx, deployment); err != nil { - return fmt.Errorf("failed to deploy Sequin: %w", err) - } - - // 3. Deploy Sequin service - service := r.buildSequinService(project) - if err := r.createOrUpdate(ctx, service); err != nil { - return fmt.Errorf("failed to create Sequin service: %w", err) - } - - // 4. Update status - if r.isDeploymentReady(ctx, deployment) { - r.setCondition(project, ConditionTypeSequinReady, metav1.ConditionTrue, "Ready", "Sequin is ready") - } else { - r.setCondition(project, ConditionTypeSequinReady, metav1.ConditionFalse, "Pending", "Waiting for Sequin") - } - - return nil -} -``` - -### Key Implementation Details - -- **Conditional deployment:** Only create resources if spec section exists (`if project.Spec.Sequin != nil`) -- **Secret reuse:** Check if secrets exist before generating (prevent regeneration on restart) -- **CNPG cluster updates:** Merge new roles/databases/publications into existing cluster spec -- **Error handling:** Set appropriate conditions on failure, return error for retry -- **Requeue logic:** Use `RequeueAfter` for waiting on dependencies (e.g., database ready) - -## Example Usage - -### Minimal Example (All Defaults) - -```yaml -apiVersion: supabase.guion.ai/v1alpha1 -kind: SupabaseProject -metadata: - name: my-app - namespace: apps-dev -spec: - database: - instances: 1 - storage: - size: 10Gi - - auth: - siteURL: https://my-app.example.com - externalURL: https://my-app.example.com/auth - - # Enable Sequin with all defaults (auto-deployed Redis) - sequin: {} - - # Enable Powersync with all defaults (todolist sync rules) - powersync: {} - - # Enable Meilisearch with all defaults (10Gi storage) - meilisearch: {} -``` - -**Result:** -- Supabase core stack deployed -- Sequin with bundled Redis (1Gi) -- Powersync with default todolist sync rules -- Meilisearch with 10Gi storage -- All secrets auto-generated -- All CDC roles/publications configured automatically - -### Production Example (Custom Configuration) - -```yaml -apiVersion: supabase.guion.ai/v1alpha1 -kind: SupabaseProject -metadata: - name: production-app - namespace: apps-prod -spec: - database: - instances: 3 - storage: - size: 100Gi - storageClass: longhorn - - auth: - siteURL: https://app.example.com - externalURL: https://app.example.com/auth - - # Sequin with external Redis - sequin: - replicas: 2 - redis: - external: - host: redis.infra-prod.svc - port: 6379 - resources: - requests: - memory: 512Mi - cpu: 200m - limits: - memory: 1Gi - cpu: 1 - - # Powersync with custom sync rules ConfigMap - powersync: - api: - replicas: 3 - resources: - requests: - memory: 512Mi - cpu: 200m - limits: - memory: 1Gi - cpu: 2 - replication: - resources: - requests: - memory: 1Gi - cpu: 200m - limits: - memory: 2Gi - cpu: 2 - syncRules: - configMapRef: my-custom-sync-rules - compact: - schedule: "0 2 * * *" # 2am daily - - # Meilisearch with larger storage - meilisearch: - replicas: 2 - persistence: - size: 50Gi - storageClass: longhorn - resources: - requests: - memory: 1Gi - cpu: 500m - limits: - memory: 4Gi - cpu: 2 -``` - -### FlickNote Reference Example - -Replicates exact flicknote-deploy configuration: - -```yaml -apiVersion: supabase.guion.ai/v1alpha1 -kind: SupabaseProject -metadata: - name: flicknote - namespace: apps-prod -spec: - database: - instances: 1 - storage: - size: 20Gi - storageClass: local-path - - auth: - siteURL: https://flicknote.app - externalURL: https://api.flicknote.app/auth - - # Sequin - FlickNote custom fork - sequin: - image: - registry: ghcr.io - repository: guionai/sequin - tag: flicknote - pullPolicy: IfNotPresent - replicas: 1 - redis: - external: - host: redis.infra-prod.svc - port: 6379 - resources: - requests: - memory: 256Mi - cpu: 100m - limits: - memory: 512Mi - cpu: 500m - - # Powersync - matches flicknote-deploy values - powersync: - image: - repository: journeyapps/powersync-service - tag: "1.18.2" - pullPolicy: IfNotPresent - api: - replicas: 2 - resources: - requests: - memory: 360Mi - cpu: 100m - limits: - memory: 360Mi - cpu: 1 - nodeOptions: "--max-old-space-size=330" - replication: - resources: - requests: - memory: 512Mi - cpu: 100m - limits: - memory: 512Mi - cpu: 1 - nodeOptions: "--max-old-space-size=482" - syncRules: - configMapRef: powersync-sync-rules # External ConfigMap from Tanka - compact: - schedule: "0 3 * * *" - - # Meilisearch - matches flicknote-deploy values - meilisearch: - image: - repository: getmeili/meilisearch - tag: v1.11.0 - replicas: 1 - persistence: - size: 10Gi - storageClass: local-path - resources: - requests: - memory: 512Mi - cpu: 250m - limits: - memory: 2Gi - cpu: 500m -``` - -## Open Questions & Research Tasks - -### ~~Task #1: CDC Permissions SQL Ordering~~ ✅ RESOLVED - -**Status:** ✅ Resolved - using dbmate migration Job - -**Solution:** Create a Kubernetes Job that runs dbmate to apply CDC permissions after CNPG cluster is ready. - -**Implementation details:** -- ConfigMap with single migration: `20260207000001_cdc_grants.sql` -- Job uses `ghcr.io/amacneil/dbmate:2.24` image -- Custom migrations table: `--migrations-table=cloudnative_supabase_schema_migrations` (avoids conflict with application migrations) -- InitContainer waits for auth.users table (ensures CNPG fully ready) -- Job runs `dbmate up --no-dump-schema` -- Idempotent: dbmate tracks applied migrations, safe to re-run - -**Why this approach:** -- ✅ Clear ordering: Runs after CNPG cluster + managed roles ready -- ✅ Mirrors flicknote-deploy pattern (proven in production) -- ✅ Idempotent via dbmate's schema_migrations tracking -- ✅ Status tracking via ConditionTypeCDCReady -- ✅ No conflict with application's dbmate migrations (separate table) - -Reference implementation: `/Users/neil/Code/guion/flick-backend-31/tanka/charts/db-init` - -### Other Open Questions - -1. **Sequin init configuration** - How to inject account/user/API token setup? - - Current: Uses `configuration` field in Helm chart - - Need: Operator approach for initial setup - -2. **Powersync default sync rules** - Exact YAML to use for default - - Copy from flicknote-deploy chart's todolist example - -3. **Redis password** - Should bundled Redis have auth enabled? - - flicknote-deploy Redis is passwordless (internal only) - - Bundled Redis should match (ClusterIP, no auth) - -4. **Metrics validation** - Ensure all services expose metrics correctly - - Test Prometheus scraping on deployed services - -## Implementation Phases - -### Phase 1: CRD + Basic Sequin -- Define CRD API structs (SequinSpec, PowersyncSpec, MeilisearchSpec) -- Generate CRD YAML (`make generate manifests`) -- Implement secret generation (Sequin secrets) -- Implement Sequin deployment (without bundled Redis) -- Test with external Redis - -### Phase 2: Powersync + Auto CDC Config -- Implement Powersync deployments (API, Replication, CronJob) -- Implement sync rules ConfigMap generation -- Extend CNPG cluster builder with CDC roles/publications -- Resolve Task #1 (CDC permissions SQL ordering) -- Test CDC integration end-to-end - -### Phase 3: Meilisearch + Bundled Redis -- Implement Meilisearch StatefulSet -- Implement master key generation -- Implement bundled Redis option for Sequin -- Test storage persistence - -### Phase 4: Documentation + Polish -- Write CRD reference documentation -- Write quick start guide -- Write FlickNote configuration guide -- Add E2E tests -- Performance testing - -## Testing Strategy - -### Unit Tests -- Builder functions for all new resources (Sequin, Powersync, Meilisearch) -- Secret generation logic -- CNPG cluster extension logic (roles, publications) - -### Integration Tests -- envtest with CNPG CRDs installed -- Test reconciliation flow with mocked CNPG Cluster -- Test status condition updates - -### E2E Tests -- Deploy to kind cluster -- Create SupabaseProject with all three specs -- Verify all services reach Ready status -- Test CDC functionality (Sequin replication, Powersync sync) -- Test search functionality (Meilisearch indexing) - -### Backward Compatibility Tests -- Ensure existing SupabaseProjects without CDC specs continue working -- Verify no breaking changes to existing API - -### Upgrade Tests -- Operator upgrade with existing SupabaseProjects -- Verify secrets not regenerated -- Verify no service disruption - -## Next Steps - -1. ✅ **Design complete** - Document written -2. ⏳ **Task #1 research** - CDC permissions SQL ordering -3. **Write implementation plan** - Break down Phase 1 into concrete tasks -4. **Prototype Phase 1** - CRD + basic Sequin on new project -5. **Iterate based on feedback** - Adjust design as implementation progresses - -## References - -- **flicknote-deploy repository:** `/Users/neil/Code/guion/flicknote-deploy` - - Sequin chart: `charts/sequin/` - - Powersync chart: `charts/powersync/` - - Meilisearch config: `flux/apps/base/meilisearch/` - -- **Current cloudnative-supabase:** - - CRD: `api/v1alpha1/supabaseproject_types.go` - - Controller: `internal/controller/supabaseproject_controller.go` - - Resource builders: `internal/resources/` - -- **External documentation:** - - [CNPG Documentation](https://cloudnative-pg.io/) - - [Sequin Documentation](https://sequinstream.com/docs) - - [Powersync Documentation](https://docs.powersync.com/) - - [Meilisearch Documentation](https://www.meilisearch.com/docs) diff --git a/internal/controller/cnpg_roles_test.go b/internal/controller/cnpg_roles_test.go new file mode 100644 index 0000000..a4e330a --- /dev/null +++ b/internal/controller/cnpg_roles_test.go @@ -0,0 +1,45 @@ +package controller + +import ( + "testing" + + cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" + "k8s.io/utils/ptr" +) + +func TestSyncManagedRolesAddsPowerSyncRoles(t *testing.T) { + existing := &cnpgv1.Cluster{ + Spec: cnpgv1.ClusterSpec{ + Managed: &cnpgv1.ManagedConfiguration{ + Roles: []cnpgv1.RoleConfiguration{{Name: "supabase_admin"}}, + }, + }, + } + desired := existing.DeepCopy() + desired.Spec.Managed.Roles = append(desired.Spec.Managed.Roles, + cnpgv1.RoleConfiguration{Name: "powersync_storage"}, + cnpgv1.RoleConfiguration{Name: "powersync_replication"}, + ) + + if !syncManagedRoles(existing, desired) { + t.Fatal("expected managed roles to change") + } + if got := len(existing.Spec.Managed.Roles); got != 3 { + t.Fatalf("managed roles = %d, want 3", got) + } + if syncManagedRoles(existing, desired) { + t.Fatal("expected identical managed roles to be a no-op") + } +} + +func TestPublicationIsApplied(t *testing.T) { + if publicationIsApplied(&cnpgv1.Publication{}) { + t.Fatal("publication without status must not be ready") + } + publication := &cnpgv1.Publication{ + Status: cnpgv1.PublicationStatus{Applied: ptr.To(true)}, + } + if !publicationIsApplied(publication) { + t.Fatal("publication with applied status must be ready") + } +} diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index c25e379..cb8637c 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -22,6 +22,7 @@ import ( "path/filepath" "testing" + cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -61,6 +62,8 @@ var _ = BeforeSuite(func() { var err error err = supabasev1alpha1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) + err = cnpgv1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) // +kubebuilder:scaffold:scheme diff --git a/internal/controller/supabaseproject_controller.go b/internal/controller/supabaseproject_controller.go index 3b8a407..6961c8d 100644 --- a/internal/controller/supabaseproject_controller.go +++ b/internal/controller/supabaseproject_controller.go @@ -28,6 +28,7 @@ import ( appsv1 "k8s.io/api/apps/v1" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -91,6 +92,7 @@ type SupabaseProjectReconciler struct { // +kubebuilder:rbac:groups=supabase.guion.dev,resources=supabaseprojects/finalizers,verbs=update // +kubebuilder:rbac:groups=postgresql.cnpg.io,resources=clusters,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=postgresql.cnpg.io,resources=scheduledbackups,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=postgresql.cnpg.io,resources=publications,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=barmancloud.cnpg.io,resources=objectstores,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch;delete @@ -98,7 +100,6 @@ type SupabaseProjectReconciler struct { // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=batch,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch;delete // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. @@ -153,30 +154,19 @@ func (r *SupabaseProjectReconciler) Reconcile(ctx context.Context, req ctrl.Requ return ctrl.Result{}, err } - // Phase 6: CDC Services (after core services) - if project.Spec.Sequin != nil || project.Spec.Powersync != nil { - if result, err := r.reconcileCDCPermissions(ctx, project); err != nil || result.RequeueAfter > 0 { + // Phase 6: PowerSync (after core services) + if project.Spec.Powersync != nil { + if result, err := r.reconcilePowerSyncPublication(ctx, project); err != nil || result.RequeueAfter > 0 { return result, err } - } - if project.Spec.Sequin != nil { - if err := r.reconcileSequin(ctx, project); err != nil { - return ctrl.Result{}, err + if result, err := r.reconcileCDCPermissions(ctx, project); err != nil || result.RequeueAfter > 0 { + return result, err } - } - if project.Spec.Powersync != nil { if err := r.reconcilePowersync(ctx, project); err != nil { return ctrl.Result{}, err } } - // Phase 7: Search Service - if project.Spec.Meilisearch != nil { - if err := r.reconcileMeilisearch(ctx, project); err != nil { - return ctrl.Result{}, err - } - } - // All phases complete project.Status.Phase = supabasev1alpha1.PhaseRunning project.Status.ObservedGeneration = project.Generation @@ -263,6 +253,12 @@ func (r *SupabaseProjectReconciler) reconcileUserSpecifiedSecrets(ctx context.Co } } + if project.Spec.Powersync != nil { + if err := r.reconcilePowersyncSecrets(ctx, project, &secretNames); err != nil { + return err + } + } + // All secrets validated successfully project.Status.SecretNames = secretNames r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionTrue, "SecretsValidated", "All user-specified secrets are valid") @@ -314,21 +310,11 @@ func (r *SupabaseProjectReconciler) reconcileAutoGeneratedSecrets(ctx context.Co log.Info("Secrets already exist in cluster, syncing status") // Also sync optional service secrets - if project.Spec.Sequin != nil { - if err := r.reconcileSequinSecrets(ctx, project, &secretNames); err != nil { - return err - } - } if project.Spec.Powersync != nil { if err := r.reconcilePowersyncSecrets(ctx, project, &secretNames); err != nil { return err } } - if project.Spec.Meilisearch != nil { - if err := r.reconcileMeilisearchSecrets(ctx, project, &secretNames); err != nil { - return err - } - } project.Status.SecretNames = secretNames r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionTrue, "SecretsExist", "All secrets exist") @@ -363,13 +349,6 @@ func (r *SupabaseProjectReconciler) reconcileAutoGeneratedSecrets(ctx context.Co } } - // Generate Sequin secrets if Sequin is enabled - if project.Spec.Sequin != nil { - if err := r.reconcileSequinSecrets(ctx, project, &secretNames); err != nil { - return err - } - } - // Generate Powersync secrets if Powersync is enabled if project.Spec.Powersync != nil { if err := r.reconcilePowersyncSecrets(ctx, project, &secretNames); err != nil { @@ -377,13 +356,6 @@ func (r *SupabaseProjectReconciler) reconcileAutoGeneratedSecrets(ctx context.Co } } - // Generate Meilisearch secrets if Meilisearch is enabled - if project.Spec.Meilisearch != nil { - if err := r.reconcileMeilisearchSecrets(ctx, project, &secretNames); err != nil { - return err - } - } - // Update status with secret names project.Status.SecretNames = secretNames @@ -395,15 +367,14 @@ func (r *SupabaseProjectReconciler) reconcileAutoGeneratedSecrets(ctx context.Co return nil } -// reconcileSequinSecrets generates Sequin-related secrets if they don't exist -func (r *SupabaseProjectReconciler) reconcileSequinSecrets(ctx context.Context, project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) error { +// reconcilePowersyncSecrets generates Powersync-related secrets if they don't exist +func (r *SupabaseProjectReconciler) reconcilePowersyncSecrets(ctx context.Context, project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) error { log := logf.FromContext(ctx) - sequinName, sequinPwdName, sequinReplPwdName := secrets.SequinSecretNames(project) + storagePwdName, replPwdName := secrets.PowersyncSecretNames(project) - // Check if all Sequin secrets exist allExist := true - for _, name := range []string{sequinName, sequinPwdName, sequinReplPwdName} { + for _, name := range []string{storagePwdName, replPwdName} { existing := &corev1.Secret{} if err := r.Get(ctx, types.NamespacedName{Name: name, Namespace: project.Namespace}, existing); err != nil { if apierrors.IsNotFound(err) { @@ -413,57 +384,11 @@ func (r *SupabaseProjectReconciler) reconcileSequinSecrets(ctx context.Context, return err } } - if allExist { - log.Info("Sequin secrets already exist, syncing status") - secretNames.Sequin = sequinName - secretNames.SequinPassword = sequinPwdName - secretNames.SequinReplicationPassword = sequinReplPwdName - return nil - } - - // Generate Sequin secrets - log.Info("Generating Sequin secrets") - sequinSecrets, err := secrets.GenerateSequinSecrets(project) - if err != nil { - r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionFalse, "SequinSecretsFailed", err.Error()) - if statusErr := r.Status().Update(ctx, project); statusErr != nil { - return statusErr - } - return err - } - - for _, secret := range sequinSecrets { - if err := r.createOrUpdateSecret(ctx, project, secret); err != nil { - r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionFalse, "CreateFailed", err.Error()) - if statusErr := r.Status().Update(ctx, project); statusErr != nil { - return statusErr - } - return err - } - } - - secretNames.Sequin = sequinName - secretNames.SequinPassword = sequinPwdName - secretNames.SequinReplicationPassword = sequinReplPwdName - return nil -} - -// reconcilePowersyncSecrets generates Powersync-related secrets if they don't exist -func (r *SupabaseProjectReconciler) reconcilePowersyncSecrets(ctx context.Context, project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) error { - log := logf.FromContext(ctx) - - storagePwdName, replPwdName := secrets.PowersyncSecretNames(project) - - // Check if both secrets exist (use storage password as the sentinel) - existing := &corev1.Secret{} - if err := r.Get(ctx, types.NamespacedName{Name: storagePwdName, Namespace: project.Namespace}, existing); err == nil { log.Info("Powersync secrets already exist, syncing status") secretNames.PowersyncStoragePassword = storagePwdName secretNames.PowersyncReplicationPassword = replPwdName return nil - } else if !apierrors.IsNotFound(err) { - return err } // Generate Powersync secrets @@ -492,53 +417,6 @@ func (r *SupabaseProjectReconciler) reconcilePowersyncSecrets(ctx context.Contex return nil } -// reconcileMeilisearchSecrets generates Meilisearch-related secrets if they don't exist -func (r *SupabaseProjectReconciler) reconcileMeilisearchSecrets(ctx context.Context, project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) error { - log := logf.FromContext(ctx) - - msSecretName := secrets.MeilisearchSecretName(project) - - // Check if secret exists - existing := &corev1.Secret{} - if err := r.Get(ctx, types.NamespacedName{Name: msSecretName, Namespace: project.Namespace}, existing); err == nil { - log.Info("Meilisearch secrets already exist, syncing status") - secretNames.MeilisearchMasterKey = msSecretName - return nil - } else if !apierrors.IsNotFound(err) { - return err - } - - // If user provided a secret ref, just use it (no generation needed) - if project.Spec.Meilisearch.MasterKeySecretRef != "" { - secretNames.MeilisearchMasterKey = project.Spec.Meilisearch.MasterKeySecretRef - return nil - } - - // Generate Meilisearch secrets - log.Info("Generating Meilisearch secrets") - msSecrets, err := secrets.GenerateMeilisearchSecrets(project) - if err != nil { - r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionFalse, "MeilisearchSecretsFailed", err.Error()) - if statusErr := r.Status().Update(ctx, project); statusErr != nil { - return statusErr - } - return err - } - - for _, secret := range msSecrets { - if err := r.createOrUpdateSecret(ctx, project, secret); err != nil { - r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionFalse, "CreateFailed", err.Error()) - if statusErr := r.Status().Update(ctx, project); statusErr != nil { - return statusErr - } - return err - } - } - - secretNames.MeilisearchMasterKey = msSecretName - return nil -} - // reconcileInitSQL ensures the init SQL ConfigMap exists func (r *SupabaseProjectReconciler) reconcileInitSQL(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { log := logf.FromContext(ctx) @@ -584,9 +462,27 @@ func (r *SupabaseProjectReconciler) reconcileCNPGCluster(ctx context.Context, pr return ctrl.Result{}, err } + // Optional PowerSync roles may be added after the cluster already exists. + if syncManagedRoles(existing, cluster) { + if err := r.Update(ctx, existing); err != nil { + return ctrl.Result{}, fmt.Errorf("updating CNPG managed roles: %w", err) + } + } + return ctrl.Result{}, nil } +func syncManagedRoles(existing, desired *cnpgv1.Cluster) bool { + if existing.Spec.Managed == nil { + existing.Spec.Managed = &cnpgv1.ManagedConfiguration{} + } + if apiequality.Semantic.DeepEqual(existing.Spec.Managed.Roles, desired.Spec.Managed.Roles) { + return false + } + existing.Spec.Managed.Roles = desired.Spec.Managed.Roles + return true +} + // waitForDatabase waits for the CNPG Cluster to be ready func (r *SupabaseProjectReconciler) waitForDatabase(ctx context.Context, project *supabasev1alpha1.SupabaseProject) (ctrl.Result, error) { log := logf.FromContext(ctx) @@ -1154,7 +1050,7 @@ func (r *SupabaseProjectReconciler) createOrUpdateScheduledBackup(ctx context.Co // reconcileCDCPermissions ensures CDC permissions are applied via a Job. // Returns RequeueAfter when the Job is still running so we don't proceed -// to deploy Sequin/Powersync before permissions exist. +// to deploy PowerSync before permissions exist. func (r *SupabaseProjectReconciler) reconcileCDCPermissions(ctx context.Context, project *supabasev1alpha1.SupabaseProject) (ctrl.Result, error) { log := logf.FromContext(ctx) log.Info("Reconciling CDC permissions") @@ -1198,79 +1094,51 @@ func (r *SupabaseProjectReconciler) reconcileCDCPermissions(ctx context.Context, } // cdcScriptHash computes a SHA-256 hash of the CDC setup script content. -// Used to detect when the spec changes (e.g., Powersync added after Sequin) -// so the Job can be recreated with the new permissions. +// Used to detect permission script changes so the Job can be recreated. func cdcScriptHash(project *supabasev1alpha1.SupabaseProject) string { cm := jobs.BuildCDCMigrationsConfigMap(project) h := sha256.Sum256([]byte(cm.Data["setup.sh"])) return hex.EncodeToString(h[:]) } -// reconcileSequin deploys the Sequin service (and bundled Redis if external is not configured) -func (r *SupabaseProjectReconciler) reconcileSequin(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { - log := logf.FromContext(ctx) - log.Info("Reconciling Sequin service") - - secretNames := &project.Status.SecretNames - - // Deploy bundled Redis if external is not configured - if project.Spec.Sequin.Redis.External == nil { - if err := r.reconcileSequinRedis(ctx, project); err != nil { - return err +func (r *SupabaseProjectReconciler) reconcilePowerSyncPublication(ctx context.Context, project *supabasev1alpha1.SupabaseProject) (ctrl.Result, error) { + desired := cnpg.BuildPowerSyncPublication(project) + existing := &cnpgv1.Publication{} + err := r.Get(ctx, types.NamespacedName{Name: desired.Name, Namespace: desired.Namespace}, existing) + if err != nil { + if !apierrors.IsNotFound(err) { + return ctrl.Result{}, err } - } - - // Create deployment - deployment := deployments.BuildSequinDeployment(project, secretNames) - if err := r.createOrUpdateDeployment(ctx, project, deployment); err != nil { - r.setCondition(project, supabasev1alpha1.ConditionTypeSequinReady, metav1.ConditionFalse, "DeploymentFailed", err.Error()) - if statusErr := r.Status().Update(ctx, project); statusErr != nil { - return statusErr + if err := controllerutil.SetControllerReference(project, desired, r.Scheme); err != nil { + return ctrl.Result{}, err } - return err - } - - // Create service - service := services.BuildSequinService(project) - if err := r.createOrUpdateService(ctx, project, service); err != nil { - r.setCondition(project, supabasev1alpha1.ConditionTypeSequinReady, metav1.ConditionFalse, "ServiceFailed", err.Error()) - if statusErr := r.Status().Update(ctx, project); statusErr != nil { - return statusErr + if err := r.Create(ctx, desired); err != nil { + return ctrl.Result{}, err } - return err - } - - project.Status.Services.Sequin = supabasev1alpha1.ServiceStatus{Ready: true} - r.setCondition(project, supabasev1alpha1.ConditionTypeSequinReady, metav1.ConditionTrue, "Ready", "Sequin service is running") - return nil -} - -// reconcileSequinRedis deploys the bundled Redis StatefulSet and Service for Sequin -func (r *SupabaseProjectReconciler) reconcileSequinRedis(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { - log := logf.FromContext(ctx) - log.Info("Reconciling bundled Redis for Sequin") - - // Create Redis StatefulSet - sts := deployments.BuildSequinRedisStatefulSet(project) - if err := r.createOrUpdateStatefulSet(ctx, project, sts); err != nil { - r.setCondition(project, supabasev1alpha1.ConditionTypeSequinReady, metav1.ConditionFalse, "RedisStatefulSetFailed", err.Error()) - if statusErr := r.Status().Update(ctx, project); statusErr != nil { - return statusErr + r.setCondition(project, supabasev1alpha1.ConditionTypeCDCReady, metav1.ConditionFalse, "PublicationPending", "Waiting for the PowerSync publication") + if err := r.Status().Update(ctx, project); err != nil { + return ctrl.Result{}, err } - return err + return ctrl.Result{RequeueAfter: RequeueDelay}, nil } - // Create Redis Service - svc := deployments.BuildSequinRedisService(project) - if err := r.createOrUpdateService(ctx, project, svc); err != nil { - r.setCondition(project, supabasev1alpha1.ConditionTypeSequinReady, metav1.ConditionFalse, "RedisServiceFailed", err.Error()) - if statusErr := r.Status().Update(ctx, project); statusErr != nil { - return statusErr + if !publicationIsApplied(existing) { + message := "Waiting for the PowerSync publication" + if existing.Status.Message != "" { + message = existing.Status.Message } - return err + r.setCondition(project, supabasev1alpha1.ConditionTypeCDCReady, metav1.ConditionFalse, "PublicationPending", message) + if err := r.Status().Update(ctx, project); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: RequeueDelay}, nil } - return nil + return ctrl.Result{}, nil +} + +func publicationIsApplied(publication *cnpgv1.Publication) bool { + return publication.Status.Applied != nil && *publication.Status.Applied } // reconcilePowersync deploys the Powersync service (API + Replication + ConfigMaps + CronJob) @@ -1279,10 +1147,9 @@ func (r *SupabaseProjectReconciler) reconcilePowersync(ctx context.Context, proj log.Info("Reconciling Powersync service") secretNames := &project.Status.SecretNames - dbHost := cnpg.ClusterRWServiceName(project) // Create Powersync config ConfigMap - psConfig := configmaps.BuildPowersyncConfigMap(project, dbHost) + psConfig := configmaps.BuildPowersyncConfigMap(project) if err := r.createOrUpdateConfigMap(ctx, project, psConfig); err != nil { r.setCondition(project, supabasev1alpha1.ConditionTypePowersyncReady, metav1.ConditionFalse, "ConfigMapFailed", err.Error()) if statusErr := r.Status().Update(ctx, project); statusErr != nil { @@ -1351,62 +1218,6 @@ func (r *SupabaseProjectReconciler) reconcilePowersync(ctx context.Context, proj return nil } -// reconcileMeilisearch deploys the Meilisearch service (StatefulSet + Service) -func (r *SupabaseProjectReconciler) reconcileMeilisearch(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { - log := logf.FromContext(ctx) - log.Info("Reconciling Meilisearch service") - - secretNames := &project.Status.SecretNames - - // Create StatefulSet - sts := deployments.BuildMeilisearchStatefulSet(project, secretNames) - if err := r.createOrUpdateStatefulSet(ctx, project, sts); err != nil { - r.setCondition(project, supabasev1alpha1.ConditionTypeMeilisearchReady, metav1.ConditionFalse, "StatefulSetFailed", err.Error()) - if statusErr := r.Status().Update(ctx, project); statusErr != nil { - return statusErr - } - return err - } - - // Create service - service := services.BuildMeilisearchService(project) - if err := r.createOrUpdateService(ctx, project, service); err != nil { - r.setCondition(project, supabasev1alpha1.ConditionTypeMeilisearchReady, metav1.ConditionFalse, "ServiceFailed", err.Error()) - if statusErr := r.Status().Update(ctx, project); statusErr != nil { - return statusErr - } - return err - } - - project.Status.Services.Meilisearch = supabasev1alpha1.ServiceStatus{Ready: true} - r.setCondition(project, supabasev1alpha1.ConditionTypeMeilisearchReady, metav1.ConditionTrue, "Ready", "Meilisearch service is running") - return nil -} - -// createOrUpdateStatefulSet creates or updates a StatefulSet resource -func (r *SupabaseProjectReconciler) createOrUpdateStatefulSet(ctx context.Context, project *supabasev1alpha1.SupabaseProject, sts *appsv1.StatefulSet) error { - log := logf.FromContext(ctx) - - if err := controllerutil.SetControllerReference(project, sts, r.Scheme); err != nil { - return err - } - - existing := &appsv1.StatefulSet{} - err := r.Get(ctx, types.NamespacedName{Name: sts.Name, Namespace: sts.Namespace}, existing) - if err != nil { - if apierrors.IsNotFound(err) { - log.Info("Creating StatefulSet", "name", sts.Name) - return r.Create(ctx, sts) - } - return err - } - - // Update existing - only update mutable fields (VolumeClaimTemplates are immutable) - existing.Spec.Replicas = sts.Spec.Replicas - existing.Spec.Template = sts.Spec.Template - return r.Update(ctx, existing) -} - // createOrUpdateCronJob creates or updates a CronJob resource func (r *SupabaseProjectReconciler) createOrUpdateCronJob(ctx context.Context, project *supabasev1alpha1.SupabaseProject, cronJob *batchv1.CronJob) error { log := logf.FromContext(ctx) @@ -1433,9 +1244,8 @@ func (r *SupabaseProjectReconciler) createOrUpdateCronJob(ctx context.Context, p const cdcScriptHashAnnotation = "supabase.guion.dev/cdc-script-hash" // createOrCheckJob creates a Job if it doesn't exist, or checks status of an existing Job. -// scriptHash is compared against an annotation on the existing Job to detect spec changes -// (e.g., Powersync added after Sequin). When the hash changes, the old Job is deleted -// and a new one is created. +// scriptHash is compared against an annotation on the existing Job. When the +// permission script changes, the old Job is deleted and a new one is created. // Returns (true, nil) when the Job has completed successfully, (false, nil) when still // running or just created, and (false, err) on failure. func (r *SupabaseProjectReconciler) createOrCheckJob(ctx context.Context, project *supabasev1alpha1.SupabaseProject, job *batchv1.Job, scriptHash string) (bool, error) { @@ -1511,10 +1321,10 @@ func (r *SupabaseProjectReconciler) SetupWithManager(mgr ctrl.Manager) error { Owns(&corev1.ConfigMap{}). Owns(&corev1.Service{}). Owns(&appsv1.Deployment{}). - Owns(&appsv1.StatefulSet{}). Owns(&batchv1.Job{}). Owns(&batchv1.CronJob{}). Owns(&cnpgv1.Cluster{}). + Owns(&cnpgv1.Publication{}). Owns(&cnpgv1.ScheduledBackup{}). Owns(&barmancloudv1.ObjectStore{}). Named("supabaseproject"). diff --git a/internal/controller/supabaseproject_controller_test.go b/internal/controller/supabaseproject_controller_test.go index 7ebf771..6e3fad9 100644 --- a/internal/controller/supabaseproject_controller_test.go +++ b/internal/controller/supabaseproject_controller_test.go @@ -19,10 +19,12 @@ package controller import ( "context" + cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -51,9 +53,19 @@ var _ = Describe("SupabaseProject Controller", func() { Name: resourceName, Namespace: "default", }, - // TODO(user): Specify other spec details if needed. + Spec: supabasev1alpha1.SupabaseProjectSpec{ + Database: supabasev1alpha1.DatabaseSpec{ + Instances: 1, + Storage: cnpgv1.StorageConfiguration{Size: "1Gi"}, + }, + Auth: supabasev1alpha1.AuthSpec{ + SiteURL: "https://app.example.com", + ExternalURL: "https://auth.example.com", + }, + }, } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + Expect(k8sClient.Get(ctx, typeNamespacedName, supabaseproject)).To(Succeed()) } }) @@ -68,8 +80,13 @@ var _ = Describe("SupabaseProject Controller", func() { }) It("should successfully reconcile the resource", func() { By("Reconciling the created resource") + fakeClient := fake.NewClientBuilder(). + WithScheme(k8sClient.Scheme()). + WithStatusSubresource(&supabasev1alpha1.SupabaseProject{}). + WithObjects(supabaseproject.DeepCopy()). + Build() controllerReconciler := &SupabaseProjectReconciler{ - Client: k8sClient, + Client: fakeClient, Scheme: k8sClient.Scheme(), } diff --git a/internal/resources/cnpg/cluster.go b/internal/resources/cnpg/cluster.go index 17c7d00..60a1380 100644 --- a/internal/resources/cnpg/cluster.go +++ b/internal/resources/cnpg/cluster.go @@ -196,44 +196,15 @@ func buildBootstrapConfiguration(project *supabasev1alpha1.SupabaseProject, secr } } -// buildAllRoles combines base Supabase roles with optional CDC/search roles +// buildAllRoles combines base Supabase roles with optional PowerSync roles. func buildAllRoles(project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) []cnpgv1.RoleConfiguration { roles := buildRoles(&project.Spec.Database, secretNames) - if project.Spec.Sequin != nil && secretNames.SequinPassword != "" { - roles = append(roles, BuildSequinRoles(secretNames)...) - } if project.Spec.Powersync != nil && secretNames.PowersyncStoragePassword != "" && secretNames.PowersyncReplicationPassword != "" { roles = append(roles, BuildPowersyncRoles(secretNames)...) } return roles } -// BuildSequinRoles returns additional CNPG roles required for Sequin CDC -func BuildSequinRoles(secretNames *supabasev1alpha1.SecretNamesStatus) []cnpgv1.RoleConfiguration { - return []cnpgv1.RoleConfiguration{ - { - Name: "sequin", - Ensure: cnpgv1.EnsurePresent, - Login: true, - PasswordSecret: &cnpgv1.LocalObjectReference{ - Name: secretNames.SequinPassword, - }, - Comment: "Sequin database owner role", - }, - { - Name: "sequin_replication", - Ensure: cnpgv1.EnsurePresent, - Login: true, - Replication: true, - BypassRLS: true, - PasswordSecret: &cnpgv1.LocalObjectReference{ - Name: secretNames.SequinReplicationPassword, - }, - Comment: "Sequin CDC replication role", - }, - } -} - // BuildPowersyncRoles returns additional CNPG roles required for Powersync. // Two roles are needed: // - powersync_storage: stores Powersync's internal sync state (checkpoints, buckets) diff --git a/internal/resources/cnpg/publication.go b/internal/resources/cnpg/publication.go new file mode 100644 index 0000000..29d985d --- /dev/null +++ b/internal/resources/cnpg/publication.go @@ -0,0 +1,31 @@ +package cnpg + +import ( + cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" + "github.com/GuionAI/cloudnative-supabase/internal/resources/common" +) + +// BuildPowerSyncPublication creates the CNPG resource that manages PowerSync's +// PostgreSQL publication, including future tables in the public schema. +func BuildPowerSyncPublication(project *supabasev1alpha1.SupabaseProject) *cnpgv1.Publication { + return &cnpgv1.Publication{ + ObjectMeta: metav1.ObjectMeta{ + Name: project.Name + "-powersync", + Namespace: project.Namespace, + Labels: common.ComponentLabels(project, "powersync-publication"), + }, + Spec: cnpgv1.PublicationSpec{ + ClusterRef: corev1.LocalObjectReference{Name: ClusterName(project)}, + Name: "powersync", + DBName: common.DatabaseName, + Target: cnpgv1.PublicationTarget{ + Objects: []cnpgv1.PublicationTargetObject{{TablesInSchema: "public"}}, + }, + ReclaimPolicy: cnpgv1.PublicationReclaimDelete, + }, + } +} diff --git a/internal/resources/cnpg/publication_test.go b/internal/resources/cnpg/publication_test.go new file mode 100644 index 0000000..2ddfd78 --- /dev/null +++ b/internal/resources/cnpg/publication_test.go @@ -0,0 +1,29 @@ +package cnpg + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" +) + +func TestBuildPowerSyncPublication(t *testing.T) { + project := &supabasev1alpha1.SupabaseProject{ + ObjectMeta: metav1.ObjectMeta{Name: "my-app", Namespace: "test-ns"}, + } + + publication := BuildPowerSyncPublication(project) + if publication.Name != "my-app-powersync" || publication.Namespace != "test-ns" { + t.Fatalf("unexpected identity: %s/%s", publication.Namespace, publication.Name) + } + if publication.Spec.ClusterRef.Name != "my-app-pg" { + t.Errorf("cluster = %q", publication.Spec.ClusterRef.Name) + } + if publication.Spec.Name != "powersync" || publication.Spec.DBName != "supabase" { + t.Errorf("unexpected PostgreSQL publication: %#v", publication.Spec) + } + if len(publication.Spec.Target.Objects) != 1 || publication.Spec.Target.Objects[0].TablesInSchema != "public" { + t.Errorf("unexpected target: %#v", publication.Spec.Target) + } +} diff --git a/internal/resources/configmaps/powersync.go b/internal/resources/configmaps/powersync.go index d6f8104..d0b3ac2 100644 --- a/internal/resources/configmaps/powersync.go +++ b/internal/resources/configmaps/powersync.go @@ -18,7 +18,6 @@ package configmaps import ( "encoding/json" - "fmt" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -46,8 +45,12 @@ func PowersyncSyncRulesConfigMapName(project *supabasev1alpha1.SupabaseProject) type powersyncConfig struct { Storage powersyncStorage `json:"storage"` Replication powersyncReplication `json:"replication"` + Dev powersyncDev `json:"dev"` ClientAuth powersyncClientAuth `json:"client_auth"` + Migrations powersyncMigrations `json:"migrations"` + Port int `json:"port"` SyncRules powersyncSyncRules `json:"sync_rules"` + Telemetry powersyncTelemetry `json:"telemetry"` } type powersyncStorage struct { @@ -71,43 +74,54 @@ type powersyncClientAuth struct { Audience []string `json:"audience"` } +type powersyncDev struct { + DemoAuth bool `json:"demo_auth"` +} + +type powersyncMigrations struct { + DisableAutoMigration bool `json:"disable_auto_migration"` +} + type powersyncSyncRules struct { - Path string `json:"path"` + Path string `json:"path"` + ExitOnError bool `json:"exit_on_error"` +} + +type powersyncTelemetry struct { + DisableTelemetrySharing bool `json:"disable_telemetry_sharing"` } // BuildPowersyncConfigMap creates the PowerSync config.json ConfigMap. -// Database credentials are injected via environment variables that PowerSync resolves at runtime. -// The config uses connection strings with env var placeholders. -// dbHost is the database hostname (e.g., from cnpg.ClusterRWServiceName) - passed as parameter to avoid import cycle. -func BuildPowersyncConfigMap(project *supabasev1alpha1.SupabaseProject, dbHost string) *corev1.ConfigMap { - - // PowerSync config uses connection URIs with credentials from env vars - // Environment variables PS_STORAGE_URI, PS_REPLICATION_URI, PS_JWT_SECRET - // are set on the deployment from K8s secrets +// Database credentials are injected via environment variable templates that +// PowerSync resolves at runtime. +func BuildPowersyncConfigMap(project *supabasev1alpha1.SupabaseProject) *corev1.ConfigMap { config := powersyncConfig{ Storage: powersyncStorage{ Type: "postgresql", - // Will be overridden by PS_POWERSYNC_STORAGE_URI env var - URI: fmt.Sprintf("postgresql://powersync_storage@%s:5432/supabase?sslmode=disable", dbHost), + URI: "{{ env.PS_POWERSYNC_STORAGE_URI }}", }, Replication: powersyncReplication{ Connections: []powersyncConnection{ { Type: "postgresql", - // Will be overridden by PS_POWERSYNC_REPLICATION_URI env var - URI: fmt.Sprintf("postgresql://powersync_replication@%s:5432/supabase?sslmode=disable", dbHost), - Tag: "default", + URI: "{{ env.PS_POWERSYNC_REPLICATION_URI }}", + Tag: "default", }, }, }, + Dev: powersyncDev{DemoAuth: false}, ClientAuth: powersyncClientAuth{ Supabase: true, SupabaseJWTSecret: "{{ env.PS_JWT_SECRET }}", Audience: []string{"authenticated"}, }, + Migrations: powersyncMigrations{DisableAutoMigration: false}, + Port: 8080, SyncRules: powersyncSyncRules{ - Path: "/powersync/sync_rules/sync_rules.yaml", + Path: "/powersync/sync_rules/sync_rules.yaml", + ExitOnError: false, }, + Telemetry: powersyncTelemetry{DisableTelemetrySharing: false}, } configJSON, _ := json.MarshalIndent(config, "", " ") @@ -134,10 +148,11 @@ func BuildPowersyncSyncRulesConfigMap(project *supabasev1alpha1.SupabaseProject) return nil } - // Use inline sync rules or default + // An empty sync config would either fail startup or accidentally broaden access + // if a permissive default were used. Admission validation also rejects this case. syncRules := spec.SyncRules.Inline if syncRules == "" { - syncRules = defaultSyncRules() + return nil } return &corev1.ConfigMap{ @@ -160,11 +175,3 @@ func SyncRulesConfigMapName(project *supabasev1alpha1.SupabaseProject) string { } return PowersyncSyncRulesConfigMapName(project) } - -func defaultSyncRules() string { - return `bucket_definitions: - global: - data: - - SELECT * FROM public.* -` -} diff --git a/internal/resources/configmaps/powersync_test.go b/internal/resources/configmaps/powersync_test.go index d467eba..1c84197 100644 --- a/internal/resources/configmaps/powersync_test.go +++ b/internal/resources/configmaps/powersync_test.go @@ -22,7 +22,11 @@ func newTestProject(namespace string) *supabasev1alpha1.SupabaseProject { Namespace: namespace, }, Spec: supabasev1alpha1.SupabaseProjectSpec{ - Powersync: &supabasev1alpha1.PowersyncSpec{}, + Powersync: &supabasev1alpha1.PowersyncSpec{ + SyncRules: supabasev1alpha1.SyncRulesSpec{ + Inline: "config:\n edition: 3\nstreams:\n notes:\n auto_subscribe: true\n query: SELECT id FROM notes WHERE user_id = auth.user_id()", + }, + }, }, } } @@ -76,9 +80,7 @@ func TestSyncRulesConfigMapName(t *testing.T) { func TestBuildPowersyncConfigMap(t *testing.T) { project := newTestProject(testNamespace) - dbHost := "my-app-rw" - - cm := BuildPowersyncConfigMap(project, dbHost) + cm := BuildPowersyncConfigMap(project) if cm.Name != "my-app-powersync-config" { t.Errorf("Name = %q, want %q", cm.Name, "my-app-powersync-config") @@ -102,8 +104,8 @@ func TestBuildPowersyncConfigMap(t *testing.T) { if config.Storage.Type != "postgresql" { t.Errorf("storage type = %q, want postgresql", config.Storage.Type) } - if !strings.Contains(config.Storage.URI, dbHost) { - t.Errorf("storage URI %q should contain %q", config.Storage.URI, dbHost) + if config.Storage.URI != "{{ env.PS_POWERSYNC_STORAGE_URI }}" { + t.Errorf("storage URI = %q, want environment template", config.Storage.URI) } // Replication @@ -117,6 +119,9 @@ func TestBuildPowersyncConfigMap(t *testing.T) { if conn.Tag != "default" { t.Errorf("connection tag = %q, want default", conn.Tag) } + if conn.URI != "{{ env.PS_POWERSYNC_REPLICATION_URI }}" { + t.Errorf("replication URI = %q, want environment template", conn.URI) + } // Client auth if !config.ClientAuth.Supabase { @@ -132,7 +137,7 @@ func TestBuildPowersyncConfigMap(t *testing.T) { } } -func TestBuildPowersyncSyncRulesConfigMap_Default(t *testing.T) { +func TestBuildPowersyncSyncRulesConfigMap_UsesSyncStreams(t *testing.T) { project := newTestProject(testNamespace) cm := BuildPowersyncSyncRulesConfigMap(project) @@ -148,14 +153,24 @@ func TestBuildPowersyncSyncRulesConfigMap_Default(t *testing.T) { if !ok { t.Fatal("sync_rules.yaml key not found") } - if !strings.Contains(syncRules, "bucket_definitions") { - t.Error("default sync rules should contain bucket_definitions") + if !strings.Contains(syncRules, "edition: 3") || !strings.Contains(syncRules, "streams:") { + t.Error("sync config should contain edition 3 streams") + } +} + +func TestBuildPowersyncSyncRulesConfigMap_RequiresConfiguration(t *testing.T) { + project := newTestProject(testNamespace) + project.Spec.Powersync.SyncRules.Inline = "" + + cm := BuildPowersyncSyncRulesConfigMap(project) + if cm != nil { + t.Error("expected nil ConfigMap when no sync config is provided") } } func TestBuildPowersyncSyncRulesConfigMap_Inline(t *testing.T) { project := newTestProject("default") - project.Spec.Powersync.SyncRules.Inline = "bucket_definitions:\n custom:\n data:\n - SELECT * FROM users" + project.Spec.Powersync.SyncRules.Inline = "config:\n edition: 3\nstreams:\n custom:\n query: SELECT id FROM users WHERE id = auth.user_id()" cm := BuildPowersyncSyncRulesConfigMap(project) if cm == nil { diff --git a/internal/resources/defaults/images.go b/internal/resources/defaults/images.go index d5943fd..f8890a7 100644 --- a/internal/resources/defaults/images.go +++ b/internal/resources/defaults/images.go @@ -26,21 +26,7 @@ const ( KongImage = "kong" KongTag = "2.8.1" - // Sequin image defaults - SequinImage = "sequin/sequin" - SequinTag = "v0.13.25" - // Powersync image defaults PowersyncImage = "journeyapps/powersync-service" - PowersyncTag = "1.18.2" - - // Meilisearch image defaults - MeilisearchImage = "getmeili/meilisearch" - MeilisearchTag = "v1.11.0" - - // Redis image defaults (bundled Redis for Sequin) - RedisImage = "redis" - RedisTag = "7" - - // Note: CDC permissions Job uses the same PostgresImage for psql compatibility + PowersyncTag = "1.20.4" ) diff --git a/internal/resources/deployments/helpers.go b/internal/resources/deployments/helpers.go index 8ae25d9..778d46e 100644 --- a/internal/resources/deployments/helpers.go +++ b/internal/resources/deployments/helpers.go @@ -17,6 +17,8 @@ limitations under the License. package deployments import ( + "fmt" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/util/intstr" @@ -25,6 +27,31 @@ import ( "github.com/GuionAI/cloudnative-supabase/internal/resources/common" ) +// ResolveImage resolves an ImageSpec to a full image reference with defaults. +func ResolveImage(spec supabasev1alpha1.ImageSpec, defaultImage, defaultTag string) string { + repository := defaultImage + if spec.Repository != "" { + repository = spec.Repository + } + tag := defaultTag + if spec.Tag != "" { + tag = spec.Tag + } + image := fmt.Sprintf("%s:%s", repository, tag) + if spec.Registry != "" { + return fmt.Sprintf("%s/%s", spec.Registry, image) + } + return image +} + +// ResolvePullPolicy returns IfNotPresent when no policy is specified. +func ResolvePullPolicy(spec supabasev1alpha1.ImageSpec) corev1.PullPolicy { + if spec.PullPolicy != "" { + return spec.PullPolicy + } + return corev1.PullIfNotPresent +} + // ProbeConfig holds configuration for building HTTP probes. // All time-related fields are in seconds. type ProbeConfig struct { diff --git a/internal/resources/deployments/meilisearch.go b/internal/resources/deployments/meilisearch.go deleted file mode 100644 index b6d1c97..0000000 --- a/internal/resources/deployments/meilisearch.go +++ /dev/null @@ -1,169 +0,0 @@ -/* -Copyright 2026 GuionAI. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package deployments - -import ( - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" - "github.com/GuionAI/cloudnative-supabase/internal/resources/common" - "github.com/GuionAI/cloudnative-supabase/internal/resources/defaults" - "github.com/GuionAI/cloudnative-supabase/internal/resources/secrets" -) - -const ( - MeilisearchComponentName = "meilisearch" - MeilisearchHTTPPort int32 = 7700 -) - -// MeilisearchStatefulSetName returns the Meilisearch StatefulSet name -func MeilisearchStatefulSetName(project *supabasev1alpha1.SupabaseProject) string { - return project.Name + "-meilisearch" -} - -// DefaultMeilisearchResources returns default resource requirements for Meilisearch -func DefaultMeilisearchResources() corev1.ResourceRequirements { - return corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceMemory: resource.MustParse("512Mi"), - corev1.ResourceCPU: resource.MustParse("250m"), - }, - Limits: corev1.ResourceList{ - corev1.ResourceMemory: resource.MustParse("2Gi"), - corev1.ResourceCPU: resource.MustParse("500m"), - }, - } -} - -// BuildMeilisearchStatefulSet creates the Meilisearch StatefulSet with persistent storage -func BuildMeilisearchStatefulSet(project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) *appsv1.StatefulSet { - spec := project.Spec.Meilisearch - name := MeilisearchStatefulSetName(project) - image := ResolveImage(spec.Image, defaults.MeilisearchImage, defaults.MeilisearchTag) - pullPolicy := ResolvePullPolicy(spec.Image) - replicas := NormalizeReplicas(spec.Replicas) - resources := normalizeMeilisearchResources(spec.Resources) - - // Storage configuration - storageSize := spec.Persistence.Size - if storageSize == "" { - storageSize = "10Gi" - } - - masterKeySecretName := secrets.MeilisearchSecretName(project) - - env := []corev1.EnvVar{ - {Name: "MEILI_ENV", Value: "production"}, - {Name: "MEILI_NO_ANALYTICS", Value: "true"}, - {Name: "MEILI_EXPERIMENTAL_LOGS_MODE", Value: "json"}, - {Name: "MEILI_DB_PATH", Value: "/meili_data/data.ms"}, - {Name: "MEILI_HTTP_ADDR", Value: "0.0.0.0:7700"}, - { - Name: "MEILI_MASTER_KEY", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: masterKeySecretName, - }, - Key: "masterKey", - }, - }, - }, - } - - sts := &appsv1.StatefulSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: project.Namespace, - Labels: common.ComponentLabels(project, MeilisearchComponentName), - }, - Spec: appsv1.StatefulSetSpec{ - ServiceName: name, - Replicas: &replicas, - Selector: &metav1.LabelSelector{ - MatchLabels: common.SelectorLabels(project, MeilisearchComponentName), - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: common.ComponentLabels(project, MeilisearchComponentName), - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: MeilisearchComponentName, - Image: image, - ImagePullPolicy: pullPolicy, - Env: env, - Ports: []corev1.ContainerPort{ - { - Name: "http", - ContainerPort: MeilisearchHTTPPort, - Protocol: corev1.ProtocolTCP, - }, - }, - LivenessProbe: BuildLivenessProbe("/health", MeilisearchHTTPPort), - ReadinessProbe: BuildReadinessProbe("/health", MeilisearchHTTPPort), - Resources: resources, - VolumeMounts: []corev1.VolumeMount{ - { - Name: "data", - MountPath: "/meili_data", - }, - }, - }, - }, - }, - }, - VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ - { - ObjectMeta: metav1.ObjectMeta{ - Name: "data", - }, - Spec: corev1.PersistentVolumeClaimSpec{ - AccessModes: []corev1.PersistentVolumeAccessMode{ - corev1.ReadWriteOnce, - }, - Resources: corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceStorage: resource.MustParse(storageSize), - }, - }, - }, - }, - }, - }, - } - - // Set storage class if specified - if spec.Persistence.StorageClass != "" { - sc := spec.Persistence.StorageClass - sts.Spec.VolumeClaimTemplates[0].Spec.StorageClassName = &sc - } - - AddImagePullSecrets(&sts.Spec.Template.Spec, project) - return sts -} - -func normalizeMeilisearchResources(resources corev1.ResourceRequirements) corev1.ResourceRequirements { - if len(resources.Requests) == 0 && len(resources.Limits) == 0 { - return DefaultMeilisearchResources() - } - return resources -} diff --git a/internal/resources/deployments/meilisearch_test.go b/internal/resources/deployments/meilisearch_test.go deleted file mode 100644 index 9732107..0000000 --- a/internal/resources/deployments/meilisearch_test.go +++ /dev/null @@ -1,194 +0,0 @@ -package deployments - -import ( - "testing" - - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" - - supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" - "github.com/GuionAI/cloudnative-supabase/internal/resources/defaults" -) - -func TestMeilisearchStatefulSetName(t *testing.T) { - project := newTestProject("default") - got := MeilisearchStatefulSetName(project) - if got != testProjectName+"-meilisearch" { - t.Errorf("MeilisearchStatefulSetName() = %q, want %q", got, testProjectName+"-meilisearch") - } -} - -func TestBuildMeilisearchStatefulSet(t *testing.T) { - project := newTestProject(testNamespace) - secretNames := newTestSecretNames() - - sts := BuildMeilisearchStatefulSet(project, secretNames) - - // Metadata - if sts.Name != testProjectName+"-meilisearch" { - t.Errorf("Name = %q, want %q", sts.Name, testProjectName+"-meilisearch") - } - if sts.Namespace != testNamespace { - t.Errorf("Namespace = %q, want %q", sts.Namespace, testNamespace) - } - - // Default replica = 1 - if *sts.Spec.Replicas != 1 { - t.Errorf("Replicas = %d, want 1", *sts.Spec.Replicas) - } - - // ServiceName - if sts.Spec.ServiceName != testProjectName+"-meilisearch" { - t.Errorf("ServiceName = %q, want %q", sts.Spec.ServiceName, testProjectName+"-meilisearch") - } - - // Container - c := sts.Spec.Template.Spec.Containers[0] - - expectedImage := defaults.MeilisearchImage + ":" + defaults.MeilisearchTag - if c.Image != expectedImage { - t.Errorf("image = %q, want %q", c.Image, expectedImage) - } - - // Port - if len(c.Ports) != 1 || c.Ports[0].ContainerPort != MeilisearchHTTPPort { - t.Errorf("expected port %d", MeilisearchHTTPPort) - } - - // Env vars - envMap := make(map[string]corev1.EnvVar) - for _, e := range c.Env { - envMap[e.Name] = e - } - - if envMap["MEILI_ENV"].Value != "production" { - t.Errorf("MEILI_ENV = %q, want %q", envMap["MEILI_ENV"].Value, "production") - } - if envMap["MEILI_NO_ANALYTICS"].Value != "true" { - t.Errorf("MEILI_NO_ANALYTICS = %q, want %q", envMap["MEILI_NO_ANALYTICS"].Value, "true") - } - if envMap["MEILI_MASTER_KEY"].ValueFrom == nil || envMap["MEILI_MASTER_KEY"].ValueFrom.SecretKeyRef.Key != "masterKey" { - t.Error("MEILI_MASTER_KEY should reference secret masterKey") - } - - // Probes - if c.LivenessProbe == nil || c.LivenessProbe.HTTPGet.Path != "/health" { - t.Error("expected liveness probe on /health") - } - if c.ReadinessProbe == nil || c.ReadinessProbe.HTTPGet.Path != "/health" { - t.Error("expected readiness probe on /health") - } - - // Volume mount - if len(c.VolumeMounts) != 1 || c.VolumeMounts[0].MountPath != "/meili_data" { - t.Error("expected /meili_data volume mount") - } - - // VolumeClaimTemplates - if len(sts.Spec.VolumeClaimTemplates) != 1 { - t.Fatalf("expected 1 VolumeClaimTemplate, got %d", len(sts.Spec.VolumeClaimTemplates)) - } - pvc := sts.Spec.VolumeClaimTemplates[0] - storageReq := pvc.Spec.Resources.Requests[corev1.ResourceStorage] - if storageReq.Cmp(resource.MustParse("10Gi")) != 0 { - t.Errorf("storage = %s, want 10Gi", storageReq.String()) - } - - // Default: no storage class - if pvc.Spec.StorageClassName != nil { - t.Errorf("expected nil StorageClassName, got %q", *pvc.Spec.StorageClassName) - } -} - -func TestBuildMeilisearchStatefulSet_CustomStorage(t *testing.T) { - project := newTestProject("default") - project.Spec.Meilisearch.Persistence.Size = "50Gi" - project.Spec.Meilisearch.Persistence.StorageClass = "longhorn" - secretNames := newTestSecretNames() - - sts := BuildMeilisearchStatefulSet(project, secretNames) - - pvc := sts.Spec.VolumeClaimTemplates[0] - storageReq := pvc.Spec.Resources.Requests[corev1.ResourceStorage] - if storageReq.Cmp(resource.MustParse("50Gi")) != 0 { - t.Errorf("storage = %s, want 50Gi", storageReq.String()) - } - if pvc.Spec.StorageClassName == nil || *pvc.Spec.StorageClassName != "longhorn" { - t.Error("expected storageClassName = longhorn") - } -} - -func TestBuildMeilisearchStatefulSet_MasterKeySecretRef(t *testing.T) { - project := newTestProject("default") - project.Spec.Meilisearch.MasterKeySecretRef = "my-existing-key" - secretNames := newTestSecretNames() - - sts := BuildMeilisearchStatefulSet(project, secretNames) - c := sts.Spec.Template.Spec.Containers[0] - - for _, e := range c.Env { - if e.Name == "MEILI_MASTER_KEY" { - if e.ValueFrom.SecretKeyRef.Name != "my-existing-key" { - t.Errorf("MEILI_MASTER_KEY secret = %q, want %q", e.ValueFrom.SecretKeyRef.Name, "my-existing-key") - } - return - } - } - t.Error("MEILI_MASTER_KEY env var not found") -} - -func TestDefaultMeilisearchResources(t *testing.T) { - res := DefaultMeilisearchResources() - if res.Requests.Memory().Cmp(resource.MustParse("512Mi")) != 0 { - t.Errorf("memory request = %s, want 512Mi", res.Requests.Memory()) - } - if res.Limits.Memory().Cmp(resource.MustParse("2Gi")) != 0 { - t.Errorf("memory limit = %s, want 2Gi", res.Limits.Memory()) - } -} - -func TestNormalizeMeilisearchResources(t *testing.T) { - // Empty uses defaults - got := normalizeMeilisearchResources(corev1.ResourceRequirements{}) - if got.Requests.Memory().Cmp(resource.MustParse("512Mi")) != 0 { - t.Errorf("expected default 512Mi, got %s", got.Requests.Memory()) - } - - // Custom preserved - custom := corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceMemory: resource.MustParse("1Gi"), - }, - } - got = normalizeMeilisearchResources(custom) - if got.Requests.Memory().Cmp(resource.MustParse("1Gi")) != 0 { - t.Errorf("expected custom 1Gi, got %s", got.Requests.Memory()) - } -} - -func TestBuildMeilisearchStatefulSet_ImagePullSecrets(t *testing.T) { - project := newTestProject("default") - project.Spec.ImagePullSecrets = []corev1.LocalObjectReference{ - {Name: "my-registry-secret"}, - } - secretNames := newTestSecretNames() - - sts := BuildMeilisearchStatefulSet(project, secretNames) - if len(sts.Spec.Template.Spec.ImagePullSecrets) != 1 { - t.Fatal("expected 1 image pull secret") - } - if sts.Spec.Template.Spec.ImagePullSecrets[0].Name != "my-registry-secret" { - t.Errorf("imagePullSecret = %q, want %q", sts.Spec.Template.Spec.ImagePullSecrets[0].Name, "my-registry-secret") - } -} - -func TestBuildMeilisearchStatefulSet_CustomReplicas(t *testing.T) { - project := newTestProject("default") - project.Spec.Meilisearch = &supabasev1alpha1.MeilisearchSpec{Replicas: 2} - secretNames := newTestSecretNames() - - sts := BuildMeilisearchStatefulSet(project, secretNames) - if *sts.Spec.Replicas != 2 { - t.Errorf("Replicas = %d, want 2", *sts.Spec.Replicas) - } -} diff --git a/internal/resources/deployments/powersync.go b/internal/resources/deployments/powersync.go index 4a68b23..e75e052 100644 --- a/internal/resources/deployments/powersync.go +++ b/internal/resources/deployments/powersync.go @@ -59,12 +59,12 @@ func PowersyncCompactCronJobName(project *supabasev1alpha1.SupabaseProject) stri func DefaultPowersyncAPIResources() corev1.ResourceRequirements { return corev1.ResourceRequirements{ Requests: corev1.ResourceList{ - corev1.ResourceMemory: resource.MustParse("256Mi"), + corev1.ResourceMemory: resource.MustParse("180Mi"), corev1.ResourceCPU: resource.MustParse("100m"), }, Limits: corev1.ResourceList{ - corev1.ResourceMemory: resource.MustParse("512Mi"), - corev1.ResourceCPU: resource.MustParse("500m"), + corev1.ResourceMemory: resource.MustParse("360Mi"), + corev1.ResourceCPU: resource.MustParse("1"), }, } } @@ -77,8 +77,8 @@ func DefaultPowersyncReplicationResources() corev1.ResourceRequirements { corev1.ResourceCPU: resource.MustParse("100m"), }, Limits: corev1.ResourceList{ - corev1.ResourceMemory: resource.MustParse("768Mi"), - corev1.ResourceCPU: resource.MustParse("500m"), + corev1.ResourceMemory: resource.MustParse("512Mi"), + corev1.ResourceCPU: resource.MustParse("1"), }, } } @@ -94,7 +94,7 @@ func BuildPowersyncAPIDeployment(project *supabasev1alpha1.SupabaseProject, secr nodeOptions := spec.API.NodeOptions if nodeOptions == "" { - nodeOptions = "--max-old-space-size=330" + nodeOptions = "--max-old-space-size=150" } env := buildPowersyncEnv(project, secretNames, nodeOptions) @@ -122,7 +122,7 @@ func BuildPowersyncAPIDeployment(project *supabasev1alpha1.SupabaseProject, secr Name: "powersync-api", Image: image, ImagePullPolicy: pullPolicy, - Command: []string{"node", "entry-api.js"}, + Args: []string{"start", "-r", "api"}, Env: env, Ports: []corev1.ContainerPort{ { @@ -136,8 +136,10 @@ func BuildPowersyncAPIDeployment(project *supabasev1alpha1.SupabaseProject, secr Protocol: corev1.ProtocolTCP, }, }, - LivenessProbe: BuildLivenessProbe("/api/status", PowersyncHTTPPort), - ReadinessProbe: BuildReadinessProbe("/api/status", PowersyncHTTPPort), + LivenessProbe: powersyncFileProbe("/app/.probes/poll", 5, 10, 30), + ReadinessProbe: powersyncFileProbe("/app/.probes/ready", 5, 10, 30), + StartupProbe: powersyncFileProbe("/app/.probes/startup", 200, 1, 1), + Lifecycle: powersyncLifecycle(), Resources: resources, VolumeMounts: powersyncVolumeMounts(), }, @@ -163,7 +165,7 @@ func BuildPowersyncReplicationDeployment(project *supabasev1alpha1.SupabaseProje nodeOptions := spec.Replication.NodeOptions if nodeOptions == "" { - nodeOptions = "--max-old-space-size=482" + nodeOptions = "--max-old-space-size=230" } env := buildPowersyncEnv(project, secretNames, nodeOptions) @@ -191,7 +193,7 @@ func BuildPowersyncReplicationDeployment(project *supabasev1alpha1.SupabaseProje Name: "powersync-replication", Image: image, ImagePullPolicy: pullPolicy, - Command: []string{"node", "entry-replication.js"}, + Args: []string{"start", "-r", "sync"}, Env: env, Ports: []corev1.ContainerPort{ { @@ -200,8 +202,12 @@ func BuildPowersyncReplicationDeployment(project *supabasev1alpha1.SupabaseProje Protocol: corev1.ProtocolTCP, }, }, - Resources: resources, - VolumeMounts: powersyncVolumeMounts(), + LivenessProbe: powersyncFileProbe("/app/.probes/poll", 5, 10, 30), + ReadinessProbe: powersyncFileProbe("/app/.probes/ready", 5, 10, 30), + StartupProbe: powersyncFileProbe("/app/.probes/startup", 200, 1, 1), + Lifecycle: powersyncLifecycle(), + Resources: resources, + VolumeMounts: powersyncVolumeMounts(), }, }, Volumes: powersyncVolumes(project), @@ -234,28 +240,34 @@ func BuildPowersyncCompactCronJob(project *supabasev1alpha1.SupabaseProject, sec env := buildPowersyncEnv(project, secretNames, "--max-old-space-size=330") - return &batchv1.CronJob{ + cronJob := &batchv1.CronJob{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: project.Namespace, Labels: common.ComponentLabels(project, PowersyncCompactComponentName), }, Spec: batchv1.CronJobSpec{ - Schedule: schedule, + Schedule: schedule, + ConcurrencyPolicy: batchv1.ForbidConcurrent, + SuccessfulJobsHistoryLimit: int32Ptr(3), + FailedJobsHistoryLimit: int32Ptr(1), + StartingDeadlineSeconds: int64Ptr(300), JobTemplate: batchv1.JobTemplateSpec{ Spec: batchv1.JobSpec{ + BackoffLimit: int32Ptr(2), + TTLSecondsAfterFinished: int32Ptr(3600), Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: common.ComponentLabels(project, PowersyncCompactComponentName), }, Spec: corev1.PodSpec{ - RestartPolicy: corev1.RestartPolicyOnFailure, + RestartPolicy: corev1.RestartPolicyNever, Containers: []corev1.Container{ { Name: "powersync-compact", Image: image, ImagePullPolicy: pullPolicy, - Command: []string{"node", "entry-compact.js"}, + Args: []string{"compact"}, Env: env, Resources: resources, VolumeMounts: powersyncVolumeMounts(), @@ -268,6 +280,28 @@ func BuildPowersyncCompactCronJob(project *supabasev1alpha1.SupabaseProject, sec }, }, } + AddImagePullSecrets(&cronJob.Spec.JobTemplate.Spec.Template.Spec, project) + return cronJob +} + +func powersyncFileProbe(path string, failureThreshold, periodSeconds, timeoutSeconds int32) *corev1.Probe { + return &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + Exec: &corev1.ExecAction{Command: []string{"cat", path}}, + }, + FailureThreshold: failureThreshold, + InitialDelaySeconds: 5, + PeriodSeconds: periodSeconds, + TimeoutSeconds: timeoutSeconds, + } +} + +func powersyncLifecycle() *corev1.Lifecycle { + return &corev1.Lifecycle{ + PreStop: &corev1.LifecycleHandler{ + Exec: &corev1.ExecAction{Command: []string{"sh", "-c", "sleep 5"}}, + }, + } } // buildPowersyncEnv builds environment variables shared by all Powersync containers @@ -277,6 +311,11 @@ func buildPowersyncEnv(project *supabasev1alpha1.SupabaseProject, secretNames *s return []corev1.EnvVar{ {Name: "POWERSYNC_CONFIG_PATH", Value: "/powersync/config/config.json"}, {Name: "NODE_OPTIONS", Value: nodeOptions}, + {Name: "LOG_FORMAT", Value: "json"}, + {Name: "METRICS_PORT", Value: "9464"}, + {Name: "MICRO_ENVIRONMENT_NAME", Value: "production"}, + {Name: "MICRO_PROBE_TYPE", Value: "fs"}, + {Name: "MICRO_SERVICE_NAME", Value: "powersync"}, // Storage password (powersync_storage role — internal sync state tables) { Name: "PS_STORAGE_PASSWORD", @@ -373,3 +412,11 @@ func normalizePowersyncResources(resources corev1.ResourceRequirements, fallback } return resources } + +func int32Ptr(value int32) *int32 { + return &value +} + +func int64Ptr(value int64) *int64 { + return &value +} diff --git a/internal/resources/deployments/powersync_test.go b/internal/resources/deployments/powersync_test.go index 6eb28da..d84c727 100644 --- a/internal/resources/deployments/powersync_test.go +++ b/internal/resources/deployments/powersync_test.go @@ -5,8 +5,20 @@ import ( supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" "github.com/GuionAI/cloudnative-supabase/internal/resources/defaults" + corev1 "k8s.io/api/core/v1" ) +func TestPowersyncCompactUsesImagePullSecrets(t *testing.T) { + project := newTestProject("default") + project.Spec.ImagePullSecrets = []corev1.LocalObjectReference{{Name: "registry-auth"}} + + cronJob := BuildPowersyncCompactCronJob(project, newTestSecretNames()) + pullSecrets := cronJob.Spec.JobTemplate.Spec.Template.Spec.ImagePullSecrets + if len(pullSecrets) != 1 || pullSecrets[0].Name != "registry-auth" { + t.Errorf("ImagePullSecrets = %v", pullSecrets) + } +} + func TestPowersyncNames(t *testing.T) { project := newTestProject("default") @@ -56,9 +68,12 @@ func TestBuildPowersyncAPIDeployment(t *testing.T) { t.Errorf("image = %q, want %q", c.Image, expectedImage) } - // Command: entry-api.js - if len(c.Command) != 2 || c.Command[1] != "entry-api.js" { - t.Errorf("Command = %v, want [node entry-api.js]", c.Command) + // The image entrypoint is node service/lib/entry.js. + if len(c.Command) != 0 { + t.Errorf("Command = %v, want image entrypoint", c.Command) + } + if len(c.Args) != 3 || c.Args[0] != "start" || c.Args[1] != "-r" || c.Args[2] != "api" { + t.Errorf("Args = %v, want [start -r api]", c.Args) } // Ports: HTTP + metrics @@ -72,12 +87,15 @@ func TestBuildPowersyncAPIDeployment(t *testing.T) { t.Errorf("metrics port = %d, want %d", c.Ports[1].ContainerPort, PowersyncMetricsPort) } - // Probes - if c.LivenessProbe == nil || c.LivenessProbe.HTTPGet.Path != "/api/status" { - t.Error("expected liveness probe on /api/status") + // PowerSync 1.20 filesystem probes. + if c.LivenessProbe == nil || c.LivenessProbe.Exec == nil || c.LivenessProbe.Exec.Command[1] != "/app/.probes/poll" { + t.Error("expected filesystem liveness probe") } - if c.ReadinessProbe == nil || c.ReadinessProbe.HTTPGet.Path != "/api/status" { - t.Error("expected readiness probe on /api/status") + if c.ReadinessProbe == nil || c.ReadinessProbe.Exec == nil || c.ReadinessProbe.Exec.Command[1] != "/app/.probes/ready" { + t.Error("expected filesystem readiness probe") + } + if c.StartupProbe == nil || c.StartupProbe.Exec == nil || c.StartupProbe.Exec.Command[1] != "/app/.probes/startup" { + t.Error("expected filesystem startup probe") } // Volume mounts @@ -145,9 +163,8 @@ func TestBuildPowersyncReplicationDeployment(t *testing.T) { c := dep.Spec.Template.Spec.Containers[0] - // Command: entry-replication.js - if len(c.Command) != 2 || c.Command[1] != "entry-replication.js" { - t.Errorf("Command = %v, want [node entry-replication.js]", c.Command) + if len(c.Args) != 3 || c.Args[0] != "start" || c.Args[1] != "-r" || c.Args[2] != "sync" { + t.Errorf("Args = %v, want [start -r sync]", c.Args) } // Only metrics port (no HTTP) @@ -158,8 +175,8 @@ func TestBuildPowersyncReplicationDeployment(t *testing.T) { // Default NODE_OPTIONS for replication for _, e := range c.Env { if e.Name == "NODE_OPTIONS" { - if e.Value != "--max-old-space-size=482" { - t.Errorf("NODE_OPTIONS = %q, want --max-old-space-size=482", e.Value) + if e.Value != "--max-old-space-size=230" { + t.Errorf("NODE_OPTIONS = %q, want --max-old-space-size=230", e.Value) } return } @@ -185,10 +202,13 @@ func TestBuildPowersyncCompactCronJob(t *testing.T) { t.Errorf("Schedule = %q, want %q", cj.Spec.Schedule, "0 3 * * *") } - // Command: entry-compact.js + // Compact uses the image entrypoint. c := cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0] - if len(c.Command) != 2 || c.Command[1] != "entry-compact.js" { - t.Errorf("Command = %v, want [node entry-compact.js]", c.Command) + if len(c.Args) != 1 || c.Args[0] != "compact" { + t.Errorf("Args = %v, want [compact]", c.Args) + } + if cj.Spec.ConcurrencyPolicy != "Forbid" { + t.Errorf("ConcurrencyPolicy = %q, want Forbid", cj.Spec.ConcurrencyPolicy) } } @@ -226,7 +246,7 @@ func TestBuildPowersyncEnvVars(t *testing.T) { envMap[e.Name] = struct{}{} } - required := []string{"POWERSYNC_CONFIG_PATH", "NODE_OPTIONS", "PS_STORAGE_PASSWORD", "PS_REPLICATION_PASSWORD", "PS_POWERSYNC_STORAGE_URI", "PS_POWERSYNC_REPLICATION_URI", "PS_JWT_SECRET"} + required := []string{"POWERSYNC_CONFIG_PATH", "NODE_OPTIONS", "LOG_FORMAT", "METRICS_PORT", "MICRO_PROBE_TYPE", "PS_STORAGE_PASSWORD", "PS_REPLICATION_PASSWORD", "PS_POWERSYNC_STORAGE_URI", "PS_POWERSYNC_REPLICATION_URI", "PS_JWT_SECRET"} for _, name := range required { if _, ok := envMap[name]; !ok { t.Errorf("missing required env var: %s", name) diff --git a/internal/resources/deployments/redis.go b/internal/resources/deployments/redis.go deleted file mode 100644 index 3dbe1a2..0000000 --- a/internal/resources/deployments/redis.go +++ /dev/null @@ -1,208 +0,0 @@ -/* -Copyright 2026 GuionAI. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package deployments - -import ( - "fmt" - - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/util/intstr" - "k8s.io/utils/ptr" - - supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" - "github.com/GuionAI/cloudnative-supabase/internal/resources/common" - "github.com/GuionAI/cloudnative-supabase/internal/resources/defaults" -) - -const ( - RedisComponentName = "sequin-redis" - RedisPort int32 = 6379 -) - -// SequinRedisStatefulSetName returns the bundled Redis StatefulSet name -func SequinRedisStatefulSetName(project *supabasev1alpha1.SupabaseProject) string { - return project.Name + "-sequin-redis" -} - -// SequinRedisServiceName returns the bundled Redis service name -func SequinRedisServiceName(project *supabasev1alpha1.SupabaseProject) string { - return project.Name + "-sequin-redis" -} - -// DefaultRedisResources returns default resource requirements for bundled Redis -func DefaultRedisResources() corev1.ResourceRequirements { - return corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceMemory: resource.MustParse("128Mi"), - corev1.ResourceCPU: resource.MustParse("50m"), - }, - Limits: corev1.ResourceList{ - corev1.ResourceMemory: resource.MustParse("256Mi"), - corev1.ResourceCPU: resource.MustParse("200m"), - }, - } -} - -// BuildSequinRedisStatefulSet creates a minimal single-replica Redis StatefulSet for Sequin. -// Matches the flicknote-deploy Redis chart: AOF persistence, non-root, TCP+CLI probes. -func BuildSequinRedisStatefulSet(project *supabasev1alpha1.SupabaseProject) *appsv1.StatefulSet { - spec := project.Spec.Sequin - name := SequinRedisStatefulSetName(project) - image := fmt.Sprintf("%s:%s", defaults.RedisImage, defaults.RedisTag) - - resources := normalizeRedisResources(spec.Redis.Resources) - - storageSize := spec.Redis.Storage.Size - if storageSize == "" { - storageSize = "2Gi" - } - - var replicas int32 = 1 - - sts := &appsv1.StatefulSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: project.Namespace, - Labels: common.ComponentLabels(project, RedisComponentName), - }, - Spec: appsv1.StatefulSetSpec{ - ServiceName: name, - Replicas: &replicas, - Selector: &metav1.LabelSelector{ - MatchLabels: common.SelectorLabels(project, RedisComponentName), - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: common.ComponentLabels(project, RedisComponentName), - }, - Spec: corev1.PodSpec{ - SecurityContext: &corev1.PodSecurityContext{ - RunAsNonRoot: ptr.To(true), - RunAsUser: ptr.To(int64(999)), - RunAsGroup: ptr.To(int64(1000)), - FSGroup: ptr.To(int64(1000)), - }, - Containers: []corev1.Container{ - { - Name: "redis", - Image: image, - SecurityContext: &corev1.SecurityContext{ - AllowPrivilegeEscalation: ptr.To(false), - Capabilities: &corev1.Capabilities{ - Drop: []corev1.Capability{"ALL"}, - }, - }, - Command: []string{"redis-server", "--appendonly", "yes"}, - Ports: []corev1.ContainerPort{ - { - Name: "redis", - ContainerPort: RedisPort, - Protocol: corev1.ProtocolTCP, - }, - }, - Resources: resources, - VolumeMounts: []corev1.VolumeMount{ - { - Name: "data", - MountPath: "/data", - }, - }, - LivenessProbe: &corev1.Probe{ - ProbeHandler: corev1.ProbeHandler{ - TCPSocket: &corev1.TCPSocketAction{ - Port: intstr.FromString("redis"), - }, - }, - InitialDelaySeconds: 30, - PeriodSeconds: 10, - }, - ReadinessProbe: &corev1.Probe{ - ProbeHandler: corev1.ProbeHandler{ - Exec: &corev1.ExecAction{ - Command: []string{"redis-cli", "ping"}, - }, - }, - InitialDelaySeconds: 5, - PeriodSeconds: 5, - }, - }, - }, - }, - }, - VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ - { - ObjectMeta: metav1.ObjectMeta{ - Name: "data", - }, - Spec: corev1.PersistentVolumeClaimSpec{ - AccessModes: []corev1.PersistentVolumeAccessMode{ - corev1.ReadWriteOnce, - }, - Resources: corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceStorage: resource.MustParse(storageSize), - }, - }, - }, - }, - }, - }, - } - - // Set storage class if specified - if spec.Redis.Storage.StorageClass != "" { - sc := spec.Redis.Storage.StorageClass - sts.Spec.VolumeClaimTemplates[0].Spec.StorageClassName = &sc - } - - AddImagePullSecrets(&sts.Spec.Template.Spec, project) - return sts -} - -// BuildSequinRedisService creates the ClusterIP service for bundled Redis -func BuildSequinRedisService(project *supabasev1alpha1.SupabaseProject) *corev1.Service { - name := SequinRedisServiceName(project) - return &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: project.Namespace, - Labels: common.ComponentLabels(project, RedisComponentName), - }, - Spec: corev1.ServiceSpec{ - Type: corev1.ServiceTypeClusterIP, - Selector: common.SelectorLabels(project, RedisComponentName), - Ports: []corev1.ServicePort{ - { - Name: "redis", - Port: RedisPort, - TargetPort: intstr.FromString("redis"), - Protocol: corev1.ProtocolTCP, - }, - }, - }, - } -} - -func normalizeRedisResources(resources corev1.ResourceRequirements) corev1.ResourceRequirements { - if len(resources.Requests) == 0 && len(resources.Limits) == 0 { - return DefaultRedisResources() - } - return resources -} diff --git a/internal/resources/deployments/redis_test.go b/internal/resources/deployments/redis_test.go deleted file mode 100644 index dac0843..0000000 --- a/internal/resources/deployments/redis_test.go +++ /dev/null @@ -1,159 +0,0 @@ -package deployments - -import ( - "testing" - - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" -) - -func TestSequinRedisStatefulSetName(t *testing.T) { - project := newTestProject("default") - got := SequinRedisStatefulSetName(project) - if got != testProjectName+"-sequin-redis" { - t.Errorf("SequinRedisStatefulSetName() = %q, want %q", got, testProjectName+"-sequin-redis") - } -} - -func TestSequinRedisServiceName(t *testing.T) { - project := newTestProject("default") - got := SequinRedisServiceName(project) - if got != testProjectName+"-sequin-redis" { - t.Errorf("SequinRedisServiceName() = %q, want %q", got, testProjectName+"-sequin-redis") - } -} - -func TestBuildSequinRedisStatefulSet(t *testing.T) { - project := newTestProject(testNamespace) - - sts := BuildSequinRedisStatefulSet(project) - - // Metadata - if sts.Name != testProjectName+"-sequin-redis" { - t.Errorf("Name = %q, want %q", sts.Name, testProjectName+"-sequin-redis") - } - if sts.Namespace != testNamespace { - t.Errorf("Namespace = %q, want %q", sts.Namespace, testNamespace) - } - - // Always single replica - if *sts.Spec.Replicas != 1 { - t.Errorf("Replicas = %d, want 1", *sts.Spec.Replicas) - } - - // ServiceName matches StatefulSet name - if sts.Spec.ServiceName != testProjectName+"-sequin-redis" { - t.Errorf("ServiceName = %q, want %q", sts.Spec.ServiceName, testProjectName+"-sequin-redis") - } - - // Container - containers := sts.Spec.Template.Spec.Containers - if len(containers) != 1 { - t.Fatalf("expected 1 container, got %d", len(containers)) - } - c := containers[0] - - if c.Name != "redis" { - t.Errorf("container name = %q, want %q", c.Name, "redis") - } - - // AOF persistence command - if len(c.Command) != 3 || c.Command[0] != "redis-server" || c.Command[2] != "yes" { - t.Errorf("Command = %v, want [redis-server --appendonly yes]", c.Command) - } - - // Port - if len(c.Ports) != 1 || c.Ports[0].ContainerPort != RedisPort { - t.Errorf("expected port %d", RedisPort) - } - - // Probes - if c.LivenessProbe == nil || c.LivenessProbe.TCPSocket == nil { - t.Error("expected TCP liveness probe") - } - if c.ReadinessProbe == nil || c.ReadinessProbe.Exec == nil { - t.Error("expected exec readiness probe (redis-cli ping)") - } - - // Volume mount - if len(c.VolumeMounts) != 1 || c.VolumeMounts[0].MountPath != "/data" { - t.Error("expected /data volume mount") - } - - // Security context: non-root - podSec := sts.Spec.Template.Spec.SecurityContext - if podSec == nil || !*podSec.RunAsNonRoot { - t.Error("expected RunAsNonRoot=true") - } - - // Container security: drop ALL capabilities, no privilege escalation - if c.SecurityContext == nil { - t.Fatal("expected container security context") - } - if c.SecurityContext.AllowPrivilegeEscalation == nil || *c.SecurityContext.AllowPrivilegeEscalation { - t.Error("expected AllowPrivilegeEscalation=false") - } - if c.SecurityContext.Capabilities == nil || len(c.SecurityContext.Capabilities.Drop) == 0 { - t.Error("expected dropped capabilities") - } - - // VolumeClaimTemplates - if len(sts.Spec.VolumeClaimTemplates) != 1 { - t.Fatalf("expected 1 VolumeClaimTemplate, got %d", len(sts.Spec.VolumeClaimTemplates)) - } - pvc := sts.Spec.VolumeClaimTemplates[0] - if pvc.Name != "data" { - t.Errorf("PVC name = %q, want %q", pvc.Name, "data") - } - - // Default storage size - storageReq := pvc.Spec.Resources.Requests[corev1.ResourceStorage] - if storageReq.Cmp(resource.MustParse("2Gi")) != 0 { - t.Errorf("storage = %s, want 2Gi", storageReq.String()) - } -} - -func TestBuildSequinRedisStatefulSet_CustomStorage(t *testing.T) { - project := newTestProject("default") - project.Spec.Sequin.Redis.Storage.Size = "5Gi" - project.Spec.Sequin.Redis.Storage.StorageClass = "fast-ssd" - - sts := BuildSequinRedisStatefulSet(project) - - pvc := sts.Spec.VolumeClaimTemplates[0] - storageReq := pvc.Spec.Resources.Requests[corev1.ResourceStorage] - if storageReq.Cmp(resource.MustParse("5Gi")) != 0 { - t.Errorf("storage = %s, want 5Gi", storageReq.String()) - } - if pvc.Spec.StorageClassName == nil || *pvc.Spec.StorageClassName != "fast-ssd" { - t.Error("expected storageClassName = fast-ssd") - } -} - -func TestBuildSequinRedisService(t *testing.T) { - project := newTestProject(testNamespace) - svc := BuildSequinRedisService(project) - - if svc.Name != testProjectName+"-sequin-redis" { - t.Errorf("Name = %q, want %q", svc.Name, testProjectName+"-sequin-redis") - } - if svc.Namespace != testNamespace { - t.Errorf("Namespace = %q, want %q", svc.Namespace, testNamespace) - } - if svc.Spec.Type != corev1.ServiceTypeClusterIP { - t.Errorf("Type = %q, want ClusterIP", svc.Spec.Type) - } - if len(svc.Spec.Ports) != 1 || svc.Spec.Ports[0].Port != RedisPort { - t.Errorf("expected port %d", RedisPort) - } -} - -func TestDefaultRedisResources(t *testing.T) { - res := DefaultRedisResources() - if res.Requests.Memory().Cmp(resource.MustParse("128Mi")) != 0 { - t.Errorf("memory request = %s, want 128Mi", res.Requests.Memory()) - } - if res.Limits.Memory().Cmp(resource.MustParse("256Mi")) != 0 { - t.Errorf("memory limit = %s, want 256Mi", res.Limits.Memory()) - } -} diff --git a/internal/resources/deployments/sequin.go b/internal/resources/deployments/sequin.go deleted file mode 100644 index b0adc6d..0000000 --- a/internal/resources/deployments/sequin.go +++ /dev/null @@ -1,247 +0,0 @@ -/* -Copyright 2026 GuionAI. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package deployments - -import ( - "fmt" - - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" - "github.com/GuionAI/cloudnative-supabase/internal/resources/cnpg" - "github.com/GuionAI/cloudnative-supabase/internal/resources/common" - "github.com/GuionAI/cloudnative-supabase/internal/resources/defaults" -) - -const ( - SequinComponentName = "sequin" - SequinHTTPPort = 7376 - SequinMetricsPort = 4000 -) - -// SequinDeploymentName returns the Sequin deployment name -func SequinDeploymentName(project *supabasev1alpha1.SupabaseProject) string { - return project.Name + "-sequin" -} - -// ResolveImage resolves an ImageSpec to a full image string with defaults -func ResolveImage(spec supabasev1alpha1.ImageSpec, defaultImage, defaultTag string) string { - repo := defaultImage - if spec.Repository != "" { - repo = spec.Repository - } - tag := defaultTag - if spec.Tag != "" { - tag = spec.Tag - } - image := fmt.Sprintf("%s:%s", repo, tag) - if spec.Registry != "" { - image = fmt.Sprintf("%s/%s", spec.Registry, image) - } - return image -} - -// ResolvePullPolicy resolves pull policy from ImageSpec with IfNotPresent default -func ResolvePullPolicy(spec supabasev1alpha1.ImageSpec) corev1.PullPolicy { - if spec.PullPolicy != "" { - return spec.PullPolicy - } - return corev1.PullIfNotPresent -} - -// DefaultSequinResources returns default resource requirements for Sequin -func DefaultSequinResources() corev1.ResourceRequirements { - return corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceMemory: resource.MustParse("256Mi"), - corev1.ResourceCPU: resource.MustParse("100m"), - }, - Limits: corev1.ResourceList{ - corev1.ResourceMemory: resource.MustParse("512Mi"), - corev1.ResourceCPU: resource.MustParse("500m"), - }, - } -} - -// NormalizeSequinResources returns provided resources if set, otherwise returns defaults -func NormalizeSequinResources(resources corev1.ResourceRequirements) corev1.ResourceRequirements { - if len(resources.Requests) == 0 && len(resources.Limits) == 0 { - return DefaultSequinResources() - } - return resources -} - -// BuildSequinDeployment creates the Sequin deployment -func BuildSequinDeployment(project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) *appsv1.Deployment { - spec := project.Spec.Sequin - name := SequinDeploymentName(project) - dbHost := cnpg.ClusterRWServiceName(project) - - image := ResolveImage(spec.Image, defaults.SequinImage, defaults.SequinTag) - pullPolicy := ResolvePullPolicy(spec.Image) - replicas := NormalizeReplicas(spec.Replicas) - resources := NormalizeSequinResources(spec.Resources) - - env := buildSequinEnv(project, secretNames, dbHost) - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: project.Namespace, - Labels: common.ComponentLabels(project, SequinComponentName), - Annotations: common.ReloaderAnnotations(), - }, - Spec: appsv1.DeploymentSpec{ - Replicas: &replicas, - Selector: &metav1.LabelSelector{ - MatchLabels: common.SelectorLabels(project, SequinComponentName), - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: common.ComponentLabels(project, SequinComponentName), - Annotations: common.ReloaderAnnotations(), - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: SequinComponentName, - Image: image, - ImagePullPolicy: pullPolicy, - Env: env, - Ports: []corev1.ContainerPort{ - { - Name: "http", - ContainerPort: SequinHTTPPort, - Protocol: corev1.ProtocolTCP, - }, - { - Name: "metrics", - ContainerPort: SequinMetricsPort, - Protocol: corev1.ProtocolTCP, - }, - }, - LivenessProbe: BuildHTTPProbe(ProbeConfig{ - Path: "/health", - Port: SequinHTTPPort, - InitialDelaySeconds: 30, - PeriodSeconds: 10, - TimeoutSeconds: 5, - }), - ReadinessProbe: BuildHTTPProbe(ProbeConfig{ - Path: "/health", - Port: SequinHTTPPort, - InitialDelaySeconds: 10, - PeriodSeconds: 5, - TimeoutSeconds: 3, - }), - Resources: resources, - }, - }, - }, - }, - }, - } - - AddImagePullSecrets(&deployment.Spec.Template.Spec, project) - - return deployment -} - -// buildSequinEnv builds environment variables for the Sequin deployment -func buildSequinEnv(project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus, dbHost string) []corev1.EnvVar { - spec := project.Spec.Sequin - - // Build Redis URL - external takes precedence, otherwise use bundled Redis service - var redisURL string - if spec.Redis.External != nil { - port := spec.Redis.External.Port - if port == 0 { - port = 6379 - } - redisURL = fmt.Sprintf("redis://%s:%d", spec.Redis.External.Host, port) - } else { - redisURL = fmt.Sprintf("redis://%s:%d", SequinRedisServiceName(project), RedisPort) - } - - env := []corev1.EnvVar{ - // Sequin database connection (sequin's own database) - {Name: "PG_HOSTNAME", Value: dbHost}, - {Name: "PG_PORT", Value: "5432"}, - {Name: "PG_DATABASE", Value: "sequin"}, - { - Name: "PG_USERNAME", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: secretNames.SequinPassword, - }, - Key: "username", - }, - }, - }, - { - Name: "PG_PASSWORD", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: secretNames.SequinPassword, - }, - Key: "password", - }, - }, - }, - - // Redis - {Name: "REDIS_URL", Value: redisURL}, - - // Sequin configuration - {Name: "SEQUIN_ENV", Value: "prod"}, - {Name: "PHX_HOST", Value: SequinDeploymentName(project)}, - {Name: "PORT", Value: fmt.Sprintf("%d", SequinHTTPPort)}, - - // Secret key base - { - Name: "SECRET_KEY_BASE", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: secretNames.Sequin, - }, - Key: "secretKeyBase", - }, - }, - }, - - // Vault key - { - Name: "VAULT_KEY", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: secretNames.Sequin, - }, - Key: "vaultKey", - }, - }, - }, - } - - return env -} diff --git a/internal/resources/deployments/sequin_test.go b/internal/resources/deployments/sequin_test.go deleted file mode 100644 index 4db787d..0000000 --- a/internal/resources/deployments/sequin_test.go +++ /dev/null @@ -1,326 +0,0 @@ -package deployments - -import ( - "testing" - - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" - "github.com/GuionAI/cloudnative-supabase/internal/resources/defaults" -) - -const ( - testProjectName = "my-app" - testNamespace = "test-ns" -) - -func newTestProject(namespace string) *supabasev1alpha1.SupabaseProject { - return &supabasev1alpha1.SupabaseProject{ - ObjectMeta: metav1.ObjectMeta{ - Name: testProjectName, - Namespace: namespace, - }, - Spec: supabasev1alpha1.SupabaseProjectSpec{ - Sequin: &supabasev1alpha1.SequinSpec{}, - Powersync: &supabasev1alpha1.PowersyncSpec{ - Compact: supabasev1alpha1.PowersyncCompactSpec{Enabled: true}, - }, - Meilisearch: &supabasev1alpha1.MeilisearchSpec{}, - }, - } -} - -func newTestSecretNames() *supabasev1alpha1.SecretNamesStatus { - return &supabasev1alpha1.SecretNamesStatus{ - JWT: "test-jwt", - Sequin: "test-sequin", - SequinPassword: "test-sequin-password", - SequinReplicationPassword: "test-sequin-replication-password", - PowersyncStoragePassword: "test-powersync-storage-password", - PowersyncReplicationPassword: "test-powersync-replication-password", - MeilisearchMasterKey: "test-meilisearch-master-key", - } -} - -func TestSequinDeploymentName(t *testing.T) { - project := newTestProject("default") - got := SequinDeploymentName(project) - want := "my-app-sequin" - if got != want { - t.Errorf("SequinDeploymentName() = %q, want %q", got, want) - } -} - -func TestResolveImage(t *testing.T) { - tests := []struct { - name string - spec supabasev1alpha1.ImageSpec - defaultImage string - defaultTag string - want string - }{ - { - name: "all defaults", - spec: supabasev1alpha1.ImageSpec{}, - defaultImage: "sequin/sequin", - defaultTag: "v0.13.25", - want: "sequin/sequin:v0.13.25", - }, - { - name: "custom tag", - spec: supabasev1alpha1.ImageSpec{Tag: "v1.0.0"}, - defaultImage: "sequin/sequin", - defaultTag: "v0.13.25", - want: "sequin/sequin:v1.0.0", - }, - { - name: "custom repository", - spec: supabasev1alpha1.ImageSpec{Repository: "myorg/sequin"}, - defaultImage: "sequin/sequin", - defaultTag: "v0.13.25", - want: "myorg/sequin:v0.13.25", - }, - { - name: "custom registry", - spec: supabasev1alpha1.ImageSpec{Registry: "ghcr.io"}, - defaultImage: "sequin/sequin", - defaultTag: "v0.13.25", - want: "ghcr.io/sequin/sequin:v0.13.25", - }, - { - name: "full override", - spec: supabasev1alpha1.ImageSpec{ - Registry: "ghcr.io", - Repository: "guionai/sequin", - Tag: "flicknote", - }, - defaultImage: "sequin/sequin", - defaultTag: "v0.13.25", - want: "ghcr.io/guionai/sequin:flicknote", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ResolveImage(tt.spec, tt.defaultImage, tt.defaultTag) - if got != tt.want { - t.Errorf("ResolveImage() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestResolvePullPolicy(t *testing.T) { - tests := []struct { - name string - spec supabasev1alpha1.ImageSpec - want corev1.PullPolicy - }{ - { - name: "default", - spec: supabasev1alpha1.ImageSpec{}, - want: corev1.PullIfNotPresent, - }, - { - name: "always", - spec: supabasev1alpha1.ImageSpec{PullPolicy: corev1.PullAlways}, - want: corev1.PullAlways, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ResolvePullPolicy(tt.spec) - if got != tt.want { - t.Errorf("ResolvePullPolicy() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestBuildSequinDeployment(t *testing.T) { - project := newTestProject(testNamespace) - secretNames := newTestSecretNames() - - dep := BuildSequinDeployment(project, secretNames) - - // Metadata - if dep.Name != "my-app-sequin" { - t.Errorf("Name = %q, want %q", dep.Name, "my-app-sequin") - } - if dep.Namespace != "test-ns" { - t.Errorf("Namespace = %q, want %q", dep.Namespace, "test-ns") - } - - // Replicas default to 1 - if *dep.Spec.Replicas != 1 { - t.Errorf("Replicas = %d, want 1", *dep.Spec.Replicas) - } - - // Container - containers := dep.Spec.Template.Spec.Containers - if len(containers) != 1 { - t.Fatalf("expected 1 container, got %d", len(containers)) - } - c := containers[0] - - if c.Name != SequinComponentName { - t.Errorf("container name = %q, want %q", c.Name, SequinComponentName) - } - - expectedImage := defaults.SequinImage + ":" + defaults.SequinTag - if c.Image != expectedImage { - t.Errorf("image = %q, want %q", c.Image, expectedImage) - } - - // Ports - if len(c.Ports) != 2 { - t.Fatalf("expected 2 ports, got %d", len(c.Ports)) - } - if c.Ports[0].ContainerPort != SequinHTTPPort { - t.Errorf("HTTP port = %d, want %d", c.Ports[0].ContainerPort, SequinHTTPPort) - } - if c.Ports[1].ContainerPort != SequinMetricsPort { - t.Errorf("metrics port = %d, want %d", c.Ports[1].ContainerPort, SequinMetricsPort) - } - - // Probes - if c.LivenessProbe == nil { - t.Error("expected liveness probe") - } - if c.ReadinessProbe == nil { - t.Error("expected readiness probe") - } - - // Default resources applied - if c.Resources.Requests.Memory().Cmp(resource.MustParse("256Mi")) != 0 { - t.Errorf("memory request = %s, want 256Mi", c.Resources.Requests.Memory()) - } -} - -func TestBuildSequinDeployment_CustomReplicas(t *testing.T) { - project := newTestProject("default") - project.Spec.Sequin.Replicas = 3 - secretNames := newTestSecretNames() - - dep := BuildSequinDeployment(project, secretNames) - if *dep.Spec.Replicas != 3 { - t.Errorf("Replicas = %d, want 3", *dep.Spec.Replicas) - } -} - -func TestBuildSequinDeployment_ExternalRedis(t *testing.T) { - project := newTestProject("default") - project.Spec.Sequin.Redis.External = &supabasev1alpha1.ExternalRedisSpec{ - Host: "redis.infra.svc", - Port: 6380, - } - secretNames := newTestSecretNames() - - dep := BuildSequinDeployment(project, secretNames) - env := dep.Spec.Template.Spec.Containers[0].Env - - var redisURL string - for _, e := range env { - if e.Name == "REDIS_URL" { - redisURL = e.Value - } - } - - want := "redis://redis.infra.svc:6380" - if redisURL != want { - t.Errorf("REDIS_URL = %q, want %q", redisURL, want) - } -} - -func TestBuildSequinDeployment_BundledRedis(t *testing.T) { - project := newTestProject("default") - // External is nil by default - should use bundled Redis URL - secretNames := newTestSecretNames() - - dep := BuildSequinDeployment(project, secretNames) - env := dep.Spec.Template.Spec.Containers[0].Env - - var redisURL string - for _, e := range env { - if e.Name == "REDIS_URL" { - redisURL = e.Value - } - } - - want := "redis://my-app-sequin-redis:6379" - if redisURL != want { - t.Errorf("REDIS_URL = %q, want %q", redisURL, want) - } -} - -func TestBuildSequinDeployment_EnvVars(t *testing.T) { - project := newTestProject("default") - secretNames := newTestSecretNames() - - dep := BuildSequinDeployment(project, secretNames) - env := dep.Spec.Template.Spec.Containers[0].Env - - envMap := make(map[string]corev1.EnvVar) - for _, e := range env { - envMap[e.Name] = e - } - - // Check required env vars exist - requiredEnvs := []string{"PG_HOSTNAME", "PG_PORT", "PG_DATABASE", "PG_USERNAME", "PG_PASSWORD", "REDIS_URL", "SEQUIN_ENV", "SECRET_KEY_BASE", "VAULT_KEY"} - for _, name := range requiredEnvs { - if _, ok := envMap[name]; !ok { - t.Errorf("missing required env var: %s", name) - } - } - - // Check PG_DATABASE is "sequin" - if envMap["PG_DATABASE"].Value != "sequin" { - t.Errorf("PG_DATABASE = %q, want %q", envMap["PG_DATABASE"].Value, "sequin") - } - - // Check secret refs - if envMap["PG_USERNAME"].ValueFrom.SecretKeyRef.Name != secretNames.SequinPassword { - t.Errorf("PG_USERNAME secret ref = %q, want %q", envMap["PG_USERNAME"].ValueFrom.SecretKeyRef.Name, secretNames.SequinPassword) - } - if envMap["SECRET_KEY_BASE"].ValueFrom.SecretKeyRef.Name != secretNames.Sequin { - t.Errorf("SECRET_KEY_BASE secret ref = %q, want %q", envMap["SECRET_KEY_BASE"].ValueFrom.SecretKeyRef.Name, secretNames.Sequin) - } -} - -func TestNormalizeSequinResources(t *testing.T) { - tests := []struct { - name string - resources corev1.ResourceRequirements - isDefault bool - }{ - { - name: "empty uses defaults", - resources: corev1.ResourceRequirements{}, - isDefault: true, - }, - { - name: "custom preserved", - resources: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceMemory: resource.MustParse("1Gi"), - }, - }, - isDefault: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := NormalizeSequinResources(tt.resources) - defaultRes := DefaultSequinResources() - isDefault := got.Requests.Memory().Cmp(*defaultRes.Requests.Memory()) == 0 - - if tt.isDefault != isDefault { - t.Errorf("isDefault = %v, want %v", isDefault, tt.isDefault) - } - }) - } -} diff --git a/internal/resources/deployments/test_helpers_test.go b/internal/resources/deployments/test_helpers_test.go new file mode 100644 index 0000000..1e53bbc --- /dev/null +++ b/internal/resources/deployments/test_helpers_test.go @@ -0,0 +1,28 @@ +package deployments + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" +) + +const testNamespace = "test-ns" + +func newTestProject(namespace string) *supabasev1alpha1.SupabaseProject { + return &supabasev1alpha1.SupabaseProject{ + ObjectMeta: metav1.ObjectMeta{Name: "my-app", Namespace: namespace}, + Spec: supabasev1alpha1.SupabaseProjectSpec{ + Powersync: &supabasev1alpha1.PowersyncSpec{ + Compact: supabasev1alpha1.PowersyncCompactSpec{Enabled: true}, + }, + }, + } +} + +func newTestSecretNames() *supabasev1alpha1.SecretNamesStatus { + return &supabasev1alpha1.SecretNamesStatus{ + JWT: "test-jwt", + PowersyncStoragePassword: "test-powersync-storage-password", + PowersyncReplicationPassword: "test-powersync-replication-password", + } +} diff --git a/internal/resources/jobs/cdc_permissions.go b/internal/resources/jobs/cdc_permissions.go index 4293683..90f9d44 100644 --- a/internal/resources/jobs/cdc_permissions.go +++ b/internal/resources/jobs/cdc_permissions.go @@ -59,7 +59,7 @@ func BuildCDCMigrationsConfigMap(project *supabasev1alpha1.SupabaseProject) *cor } } -// buildCDCSetupScript generates the CDC setup shell script based on enabled services +// buildCDCSetupScript generates the PowerSync database setup script. func buildCDCSetupScript(project *supabasev1alpha1.SupabaseProject) string { script := `#!/bin/sh set -e @@ -67,38 +67,6 @@ set -e echo "=== CDC Permissions Setup ===" ` - // Sequin-specific grants - if project.Spec.Sequin != nil { - script += ` -# Create sequin database if it doesn't exist -echo "Checking if sequin database exists..." -DB_EXISTS=$(psql "$PGCONNSTR" -tAc "SELECT 1 FROM pg_database WHERE datname='sequin'" 2>/dev/null || echo "0") -if [ "$DB_EXISTS" != "1" ]; then - echo "Creating sequin database..." - psql "$PGCONNSTR" -c "CREATE DATABASE sequin OWNER sequin" - echo "Sequin database created" -else - echo "Sequin database already exists" -fi - -# Apply Sequin CDC grants -echo "Applying Sequin CDC grants..." -psql "$PGCONNSTR" <<'EOSQL' --- Grant CDC role (sequin_replication) read access to public schema -GRANT USAGE ON SCHEMA public TO sequin_replication; - --- Grant sequin CREATE ON DATABASE for its migrations -GRANT CREATE ON DATABASE supabase TO sequin; - --- Grant SELECT on future tables created by supabase_admin in public schema -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT SELECT ON TABLES TO sequin_replication; - --- Grant SELECT on existing tables in public schema -GRANT SELECT ON ALL TABLES IN SCHEMA public TO sequin_replication; -EOSQL -` - } - // Powersync-specific grants if project.Spec.Powersync != nil { script += ` @@ -113,6 +81,7 @@ GRANT USAGE ON SCHEMA public TO powersync_replication; GRANT SELECT ON ALL TABLES IN SCHEMA public TO powersync_replication; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT SELECT ON TABLES TO powersync_replication; EOSQL + ` } diff --git a/internal/resources/jobs/cdc_permissions_test.go b/internal/resources/jobs/cdc_permissions_test.go new file mode 100644 index 0000000..946f58b --- /dev/null +++ b/internal/resources/jobs/cdc_permissions_test.go @@ -0,0 +1,24 @@ +package jobs + +import ( + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" +) + +func TestBuildCDCSetupScriptGrantsPowerSyncAccess(t *testing.T) { + project := &supabasev1alpha1.SupabaseProject{ + ObjectMeta: metav1.ObjectMeta{Name: "my-app", Namespace: "default"}, + Spec: supabasev1alpha1.SupabaseProjectSpec{ + Powersync: &supabasev1alpha1.PowersyncSpec{}, + }, + } + + script := BuildCDCMigrationsConfigMap(project).Data["setup.sh"] + if !strings.Contains(script, "GRANT SELECT ON ALL TABLES IN SCHEMA public TO powersync_replication") { + t.Error("CDC setup must grant PowerSync access to public tables") + } +} diff --git a/internal/resources/secrets/secrets.go b/internal/resources/secrets/secrets.go index 7b288f8..ad57e16 100644 --- a/internal/resources/secrets/secrets.go +++ b/internal/resources/secrets/secrets.go @@ -173,41 +173,6 @@ func GetSecretNamesFromSpec(spec *supabasev1alpha1.SecretsSpec) supabasev1alpha1 } } -// GenerateSequinSecrets generates all Sequin-related secrets -func GenerateSequinSecrets(project *supabasev1alpha1.SupabaseProject) ([]*corev1.Secret, error) { - var secrets []*corev1.Secret - - // Sequin application secret (secretKeyBase, vaultKey, apiToken) - appSecret, err := generateSequinAppSecret(project) - if err != nil { - return nil, fmt.Errorf("failed to generate Sequin app secret: %w", err) - } - secrets = append(secrets, appSecret) - - // Sequin database role password - sequinPassword, _, err := generateRoleSecret(project, "sequin", "sequin") - if err != nil { - return nil, fmt.Errorf("failed to generate sequin password: %w", err) - } - secrets = append(secrets, sequinPassword) - - // Sequin replication role password - replicationPassword, _, err := generateRoleSecret(project, "sequin-replication", "sequin_replication") - if err != nil { - return nil, fmt.Errorf("failed to generate sequin-replication password: %w", err) - } - secrets = append(secrets, replicationPassword) - - return secrets, nil -} - -// SequinSecretNames returns the expected secret names for Sequin -func SequinSecretNames(project *supabasev1alpha1.SupabaseProject) (sequin, sequinPassword, sequinReplicationPassword string) { - return project.Name + "-sequin", - project.Name + "-sequin-password", - project.Name + "-sequin-replication-password" -} - // GeneratePowersyncSecrets generates Powersync-related secrets func GeneratePowersyncSecrets(project *supabasev1alpha1.SupabaseProject) ([]*corev1.Secret, error) { var secrets []*corev1.Secret @@ -234,77 +199,3 @@ func PowersyncSecretNames(project *supabasev1alpha1.SupabaseProject) (powersyncS return project.Name + "-powersync-storage-password", project.Name + "-powersync-replication-password" } - -// GenerateMeilisearchSecrets generates Meilisearch-related secrets -func GenerateMeilisearchSecrets(project *supabasev1alpha1.SupabaseProject) ([]*corev1.Secret, error) { - // Use existing secret if specified - if project.Spec.Meilisearch.MasterKeySecretRef != "" { - return nil, nil - } - - secretName := MeilisearchSecretName(project) - - masterKey, err := crypto.GenerateHex(32) - if err != nil { - return nil, fmt.Errorf("generating meilisearch master key: %w", err) - } - - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: project.Namespace, - Labels: common.ComponentLabels(project, "meilisearch"), - }, - Type: corev1.SecretTypeOpaque, - StringData: map[string]string{ - "masterKey": masterKey, - }, - } - - return []*corev1.Secret{secret}, nil -} - -// MeilisearchSecretName returns the expected secret name for Meilisearch master key -func MeilisearchSecretName(project *supabasev1alpha1.SupabaseProject) string { - if project.Spec.Meilisearch != nil && project.Spec.Meilisearch.MasterKeySecretRef != "" { - return project.Spec.Meilisearch.MasterKeySecretRef - } - return project.Name + "-meilisearch-master-key" -} - -// generateSequinAppSecret creates the Sequin application secret -func generateSequinAppSecret(project *supabasev1alpha1.SupabaseProject) (*corev1.Secret, error) { - secretName := project.Name + "-sequin" - - // SECRET_KEY_BASE: 64 bytes hex (128 chars) - secretKeyBase, err := crypto.GenerateHex(64) - if err != nil { - return nil, fmt.Errorf("generating secretKeyBase: %w", err) - } - - // VAULT_KEY: 32 bytes base64 - vaultKey, err := crypto.GenerateBase64(32) - if err != nil { - return nil, fmt.Errorf("generating vaultKey: %w", err) - } - - // API token: 32 bytes hex - apiToken, err := crypto.GenerateHex(32) - if err != nil { - return nil, fmt.Errorf("generating apiToken: %w", err) - } - - return &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: project.Namespace, - Labels: common.ComponentLabels(project, "sequin"), - }, - Type: corev1.SecretTypeOpaque, - StringData: map[string]string{ - "secretKeyBase": secretKeyBase, - "vaultKey": vaultKey, - "apiToken": apiToken, - }, - }, nil -} diff --git a/internal/resources/secrets/secrets_test.go b/internal/resources/secrets/secrets_test.go index 03da603..79558b6 100644 --- a/internal/resources/secrets/secrets_test.go +++ b/internal/resources/secrets/secrets_test.go @@ -8,222 +8,40 @@ import ( supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" ) -const ( - testProjectName = "my-app" - testNamespace = "test-ns" -) - func newTestProject(namespace string) *supabasev1alpha1.SupabaseProject { return &supabasev1alpha1.SupabaseProject{ - ObjectMeta: metav1.ObjectMeta{ - Name: testProjectName, - Namespace: namespace, - }, + ObjectMeta: metav1.ObjectMeta{Name: "my-app", Namespace: namespace}, Spec: supabasev1alpha1.SupabaseProjectSpec{ - Sequin: &supabasev1alpha1.SequinSpec{}, - Powersync: &supabasev1alpha1.PowersyncSpec{}, - Meilisearch: &supabasev1alpha1.MeilisearchSpec{}, + Powersync: &supabasev1alpha1.PowersyncSpec{}, }, } } -func TestSequinSecretNames(t *testing.T) { - project := newTestProject("default") - - sequin, sequinPw, sequinRepl := SequinSecretNames(project) - - if sequin != "my-app-sequin" { - t.Errorf("sequin = %q, want %q", sequin, "my-app-sequin") - } - if sequinPw != "my-app-sequin-password" { - t.Errorf("sequinPassword = %q, want %q", sequinPw, "my-app-sequin-password") - } - if sequinRepl != "my-app-sequin-replication-password" { - t.Errorf("sequinReplication = %q, want %q", sequinRepl, "my-app-sequin-replication-password") - } -} - func TestPowersyncSecretNames(t *testing.T) { - project := newTestProject("default") - storagePwd, replPwd := PowersyncSecretNames(project) + storagePwd, replPwd := PowersyncSecretNames(newTestProject("default")) if storagePwd != "my-app-powersync-storage-password" { - t.Errorf("storagePwd = %q, want %q", storagePwd, "my-app-powersync-storage-password") + t.Errorf("storagePwd = %q", storagePwd) } if replPwd != "my-app-powersync-replication-password" { - t.Errorf("replPwd = %q, want %q", replPwd, "my-app-powersync-replication-password") - } -} - -func TestMeilisearchSecretName(t *testing.T) { - tests := []struct { - name string - masterKeyRef string - wantSecretName string - }{ - { - name: "auto-generated", - masterKeyRef: "", - wantSecretName: "my-app-meilisearch-master-key", - }, - { - name: "user-provided", - masterKeyRef: "my-existing-key", - wantSecretName: "my-existing-key", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - project := newTestProject("default") - project.Spec.Meilisearch.MasterKeySecretRef = tt.masterKeyRef - - got := MeilisearchSecretName(project) - if got != tt.wantSecretName { - t.Errorf("MeilisearchSecretName() = %q, want %q", got, tt.wantSecretName) - } - }) - } -} - -func TestGenerateSequinSecrets(t *testing.T) { - project := newTestProject(testNamespace) - - secrets, err := GenerateSequinSecrets(project) - if err != nil { - t.Fatalf("GenerateSequinSecrets() error = %v", err) - } - - if len(secrets) != 3 { - t.Fatalf("expected 3 secrets, got %d", len(secrets)) - } - - // Sequin app secret - appSecret := secrets[0] - if appSecret.Name != "my-app-sequin" { - t.Errorf("app secret name = %q, want %q", appSecret.Name, "my-app-sequin") - } - if appSecret.Namespace != testNamespace { - t.Errorf("app secret namespace = %q, want %q", appSecret.Namespace, testNamespace) - } - - requiredAppKeys := []string{"secretKeyBase", "vaultKey", "apiToken"} - for _, key := range requiredAppKeys { - if _, ok := appSecret.StringData[key]; !ok { - t.Errorf("app secret missing key: %s", key) - } - } - - // secretKeyBase should be 128 chars (64 bytes hex) - if len(appSecret.StringData["secretKeyBase"]) != 128 { - t.Errorf("secretKeyBase length = %d, want 128", len(appSecret.StringData["secretKeyBase"])) - } - - // Sequin password secret - pwSecret := secrets[1] - if pwSecret.Name != "my-app-sequin-password" { - t.Errorf("password secret name = %q, want %q", pwSecret.Name, "my-app-sequin-password") - } - if pwSecret.StringData["username"] != "sequin" { - t.Errorf("username = %q, want %q", pwSecret.StringData["username"], "sequin") - } - - // Replication password secret - replSecret := secrets[2] - if replSecret.Name != "my-app-sequin-replication-password" { - t.Errorf("replication secret name = %q, want %q", replSecret.Name, "my-app-sequin-replication-password") - } - if replSecret.StringData["username"] != "sequin_replication" { - t.Errorf("username = %q, want %q", replSecret.StringData["username"], "sequin_replication") + t.Errorf("replPwd = %q", replPwd) } } func TestGeneratePowersyncSecrets(t *testing.T) { - project := newTestProject(testNamespace) - - secrets, err := GeneratePowersyncSecrets(project) + generated, err := GeneratePowersyncSecrets(newTestProject("test-ns")) if err != nil { t.Fatalf("GeneratePowersyncSecrets() error = %v", err) } - - if len(secrets) != 2 { - t.Fatalf("expected 2 secrets, got %d", len(secrets)) - } - - // Storage role secret - storage := secrets[0] - if storage.Name != "my-app-powersync-storage-password" { - t.Errorf("storage name = %q, want %q", storage.Name, "my-app-powersync-storage-password") - } - if storage.StringData["username"] != "powersync_storage" { - t.Errorf("storage username = %q, want %q", storage.StringData["username"], "powersync_storage") - } - - // Replication role secret - repl := secrets[1] - if repl.Name != "my-app-powersync-replication-password" { - t.Errorf("replication name = %q, want %q", repl.Name, "my-app-powersync-replication-password") - } - if repl.StringData["username"] != "powersync_replication" { - t.Errorf("replication username = %q, want %q", repl.StringData["username"], "powersync_replication") - } -} - -func TestGenerateMeilisearchSecrets(t *testing.T) { - project := newTestProject(testNamespace) - - secrets, err := GenerateMeilisearchSecrets(project) - if err != nil { - t.Fatalf("GenerateMeilisearchSecrets() error = %v", err) - } - - if len(secrets) != 1 { - t.Fatalf("expected 1 secret, got %d", len(secrets)) - } - - secret := secrets[0] - if secret.Name != "my-app-meilisearch-master-key" { - t.Errorf("name = %q, want %q", secret.Name, "my-app-meilisearch-master-key") - } - if secret.Namespace != testNamespace { - t.Errorf("namespace = %q, want %q", secret.Namespace, testNamespace) - } - - // masterKey should be 64 chars (32 bytes hex) - masterKey := secret.StringData["masterKey"] - if len(masterKey) != 64 { - t.Errorf("masterKey length = %d, want 64", len(masterKey)) - } -} - -func TestGenerateMeilisearchSecrets_ExistingRef(t *testing.T) { - project := newTestProject("default") - project.Spec.Meilisearch.MasterKeySecretRef = "my-existing-key" - - secrets, err := GenerateMeilisearchSecrets(project) - if err != nil { - t.Fatalf("GenerateMeilisearchSecrets() error = %v", err) + if len(generated) != 2 { + t.Fatalf("expected 2 secrets, got %d", len(generated)) } - - if secrets != nil { - t.Errorf("expected nil secrets when MasterKeySecretRef is provided, got %d", len(secrets)) + if generated[0].StringData["username"] != "powersync_storage" { + t.Errorf("storage username = %q", generated[0].StringData["username"]) } -} - -func TestGenerateSecrets_Uniqueness(t *testing.T) { - project := newTestProject("default") - - secrets1, err := GenerateSequinSecrets(project) - if err != nil { - t.Fatalf("first call error = %v", err) + if generated[1].StringData["username"] != "powersync_replication" { + t.Errorf("replication username = %q", generated[1].StringData["username"]) } - - secrets2, err := GenerateSequinSecrets(project) - if err != nil { - t.Fatalf("second call error = %v", err) - } - - // Secrets should be different between calls (random generation) - if secrets1[0].StringData["secretKeyBase"] == secrets2[0].StringData["secretKeyBase"] { - t.Error("expected different secretKeyBase values between calls") + if generated[0].StringData["password"] == generated[1].StringData["password"] { + t.Error("storage and replication passwords must differ") } } diff --git a/internal/resources/services/services.go b/internal/resources/services/services.go index b6121d2..d06fde7 100644 --- a/internal/resources/services/services.go +++ b/internal/resources/services/services.go @@ -68,35 +68,6 @@ func BuildMetaService(project *supabasev1alpha1.SupabaseProject) *corev1.Service return BuildService(project, project.Name+"-meta", "meta", 8080) } -// BuildSequinService creates the service for Sequin with HTTP and metrics ports -func BuildSequinService(project *supabasev1alpha1.SupabaseProject) *corev1.Service { - return &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: project.Name + "-sequin", - Namespace: project.Namespace, - Labels: common.ComponentLabels(project, "sequin"), - }, - Spec: corev1.ServiceSpec{ - Type: corev1.ServiceTypeClusterIP, - Selector: common.SelectorLabels(project, "sequin"), - Ports: []corev1.ServicePort{ - { - Name: "http", - Port: 7376, - TargetPort: intstr.FromInt(7376), - Protocol: corev1.ProtocolTCP, - }, - { - Name: "metrics", - Port: 4000, - TargetPort: intstr.FromInt(4000), - Protocol: corev1.ProtocolTCP, - }, - }, - }, - } -} - // BuildPowersyncAPIService creates the service for Powersync API with HTTP and metrics ports func BuildPowersyncAPIService(project *supabasev1alpha1.SupabaseProject) *corev1.Service { return &corev1.Service{ @@ -126,11 +97,6 @@ func BuildPowersyncAPIService(project *supabasev1alpha1.SupabaseProject) *corev1 } } -// BuildMeilisearchService creates the service for Meilisearch -func BuildMeilisearchService(project *supabasev1alpha1.SupabaseProject) *corev1.Service { - return BuildService(project, project.Name+"-meilisearch", "meilisearch", 7700) -} - // BuildKongService creates the service for Kong func BuildKongService(project *supabasev1alpha1.SupabaseProject) *corev1.Service { return &corev1.Service{ diff --git a/internal/resources/services/services_test.go b/internal/resources/services/services_test.go index 8e8ca03..48737bf 100644 --- a/internal/resources/services/services_test.go +++ b/internal/resources/services/services_test.go @@ -9,131 +9,22 @@ import ( supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" ) -const ( - testProjectName = "my-app" - testNamespace = "test-ns" -) - -func newTestProject(namespace string) *supabasev1alpha1.SupabaseProject { - return &supabasev1alpha1.SupabaseProject{ - ObjectMeta: metav1.ObjectMeta{ - Name: testProjectName, - Namespace: namespace, - }, - } -} - -func TestBuildSequinService(t *testing.T) { - project := newTestProject(testNamespace) - svc := BuildSequinService(project) - - if svc.Name != "my-app-sequin" { - t.Errorf("Name = %q, want %q", svc.Name, "my-app-sequin") - } - if svc.Namespace != testNamespace { - t.Errorf("Namespace = %q, want %q", svc.Namespace, testNamespace) - } - if svc.Spec.Type != corev1.ServiceTypeClusterIP { - t.Errorf("Type = %q, want ClusterIP", svc.Spec.Type) - } - - // Should have HTTP (7376) and metrics (4000) ports - if len(svc.Spec.Ports) != 2 { - t.Fatalf("expected 2 ports, got %d", len(svc.Spec.Ports)) - } - - portMap := make(map[string]int32) - for _, p := range svc.Spec.Ports { - portMap[p.Name] = p.Port - } - - if portMap["http"] != 7376 { - t.Errorf("http port = %d, want 7376", portMap["http"]) - } - if portMap["metrics"] != 4000 { - t.Errorf("metrics port = %d, want 4000", portMap["metrics"]) - } -} - func TestBuildPowersyncAPIService(t *testing.T) { - project := newTestProject(testNamespace) - svc := BuildPowersyncAPIService(project) - - if svc.Name != "my-app-powersync-api" { - t.Errorf("Name = %q, want %q", svc.Name, "my-app-powersync-api") - } - if svc.Namespace != testNamespace { - t.Errorf("Namespace = %q, want %q", svc.Namespace, testNamespace) + project := &supabasev1alpha1.SupabaseProject{ + ObjectMeta: metav1.ObjectMeta{Name: "my-app", Namespace: "test-ns"}, } - if svc.Spec.Type != corev1.ServiceTypeClusterIP { - t.Errorf("Type = %q, want ClusterIP", svc.Spec.Type) - } - - // Should have HTTP (8080) and metrics (9464) ports - if len(svc.Spec.Ports) != 2 { - t.Fatalf("expected 2 ports, got %d", len(svc.Spec.Ports)) - } - - portMap := make(map[string]int32) - for _, p := range svc.Spec.Ports { - portMap[p.Name] = p.Port - } - - if portMap["http"] != 8080 { - t.Errorf("http port = %d, want 8080", portMap["http"]) - } - if portMap["metrics"] != 9464 { - t.Errorf("metrics port = %d, want 9464", portMap["metrics"]) - } -} - -func TestBuildMeilisearchService(t *testing.T) { - project := newTestProject(testNamespace) - svc := BuildMeilisearchService(project) + svc := BuildPowersyncAPIService(project) - if svc.Name != "my-app-meilisearch" { - t.Errorf("Name = %q, want %q", svc.Name, "my-app-meilisearch") - } - if svc.Namespace != testNamespace { - t.Errorf("Namespace = %q, want %q", svc.Namespace, testNamespace) + if svc.Name != "my-app-powersync-api" || svc.Namespace != "test-ns" { + t.Errorf("unexpected service identity: %s/%s", svc.Namespace, svc.Name) } if svc.Spec.Type != corev1.ServiceTypeClusterIP { t.Errorf("Type = %q, want ClusterIP", svc.Spec.Type) } - - // Single HTTP port (7700) - if len(svc.Spec.Ports) != 1 { - t.Fatalf("expected 1 port, got %d", len(svc.Spec.Ports)) - } - if svc.Spec.Ports[0].Port != 7700 { - t.Errorf("port = %d, want 7700", svc.Spec.Ports[0].Port) + if len(svc.Spec.Ports) != 2 || svc.Spec.Ports[0].Port != 8080 || svc.Spec.Ports[1].Port != 9464 { + t.Errorf("unexpected ports: %v", svc.Spec.Ports) } -} - -func TestServiceLabels(t *testing.T) { - project := newTestProject("default") - - tests := []struct { - name string - svc *corev1.Service - component string - }{ - {"Sequin", BuildSequinService(project), "sequin"}, - {"Powersync", BuildPowersyncAPIService(project), "powersync-api"}, - {"Meilisearch", BuildMeilisearchService(project), "meilisearch"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - labels := tt.svc.Labels - if labels["app.kubernetes.io/component"] != tt.component { - t.Errorf("component label = %q, want %q", labels["app.kubernetes.io/component"], tt.component) - } - - selector := tt.svc.Spec.Selector - if selector["app.kubernetes.io/component"] != tt.component { - t.Errorf("selector component = %q, want %q", selector["app.kubernetes.io/component"], tt.component) - } - }) + if svc.Spec.Selector["app.kubernetes.io/component"] != "powersync-api" { + t.Errorf("unexpected selector: %v", svc.Spec.Selector) } } From 6182b69a7783c4a37b1a2c0e9e0799b4bbd69cec Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 22 Jul 2026 14:19:30 +0800 Subject: [PATCH 16/21] fix(ci): install envtest before controller tests --- .github/workflows/ci.yaml | 2 +- .github/workflows/pr.yaml | 14 ++++---------- .github/workflows/release.yaml | 2 +- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 45ad1a0..c2a43f4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -28,7 +28,7 @@ jobs: run: go mod download - name: Run tests - run: go test ./... + run: make test - name: Build run: go build -o bin/manager cmd/main.go diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index ba23dc3..08abcc5 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -22,21 +22,15 @@ jobs: run: go mod download - name: Run tests - run: go test ./... + run: make test - name: Build run: go build -o bin/manager cmd/main.go - - name: Generate manifests - run: make generate manifests - - - name: Check for uncommitted changes + - name: Check generated manifests run: | - if [ -n "$(git status --porcelain)" ]; then - echo "Generated files are out of date. Run 'make generate manifests' and commit the changes." - git status --porcelain - exit 1 - fi + cp config/crd/bases/*.yaml charts/cloudnative-supabase/crds/ + git diff --exit-code lint: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 121a52d..36bcfac 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -27,7 +27,7 @@ jobs: cache: true - name: Run tests - run: go test ./... + run: make test - name: Generate and check manifests run: | From a8bfd2d64e9d1cfa5a7365165b5af8eefc2c8390 Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 22 Jul 2026 14:50:40 +0800 Subject: [PATCH 17/21] feat(deploy): add self-contained Tanka environment --- .github/workflows/ci.yaml | 8 +++++++- .github/workflows/pr.yaml | 8 +++++++- .github/workflows/release.yaml | 11 ++++++---- Makefile | 18 +++++++++++++++++ README.md | 15 ++++++++++++++ hack/test-tanka.sh | 25 +++++++++++++++++++++++ tanka/README.md | 18 +++++++++++++++++ tanka/environments/guion/main.jsonnet | 29 +++++++++++++++++++++++++++ tanka/environments/guion/spec.json | 11 ++++++++++ tanka/jsonnetfile.json | 5 +++++ 10 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 hack/test-tanka.sh create mode 100644 tanka/README.md create mode 100644 tanka/environments/guion/main.jsonnet create mode 100644 tanka/environments/guion/spec.json create mode 100644 tanka/jsonnetfile.json diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c2a43f4..5ff26ad 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,8 +27,14 @@ jobs: - name: Download dependencies run: go mod download + - name: Set up Helm + uses: azure/setup-helm@v4 + + - name: Set up Tanka + run: go install github.com/grafana/tanka/cmd/tk@v0.37.4 + - name: Run tests - run: make test + run: make test test-tanka - name: Build run: go build -o bin/manager cmd/main.go diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 08abcc5..1977544 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -21,8 +21,14 @@ jobs: - name: Download dependencies run: go mod download + - name: Set up Helm + uses: azure/setup-helm@v4 + + - name: Set up Tanka + run: go install github.com/grafana/tanka/cmd/tk@v0.37.4 + - name: Run tests - run: make test + run: make test test-tanka - name: Build run: go build -o bin/manager cmd/main.go diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 36bcfac..17c99b1 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -26,8 +26,14 @@ jobs: go-version-file: go.mod cache: true + - name: Set up Helm + uses: azure/setup-helm@v4 + + - name: Set up Tanka + run: go install github.com/grafana/tanka/cmd/tk@v0.37.4 + - name: Run tests - run: make test + run: make test test-tanka - name: Generate and check manifests run: | @@ -66,9 +72,6 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - - name: Set up Helm - uses: azure/setup-helm@v4 - - name: Package and push Helm chart run: | VERSION="${GITHUB_REF_NAME#v}" diff --git a/Makefile b/Makefile index f73308a..65f09dd 100644 --- a/Makefile +++ b/Makefile @@ -61,6 +61,24 @@ vet: ## Run go vet against code. test: manifests generate fmt vet setup-envtest ## Run tests. KUBEBUILDER_ASSETS="$(shell "$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path)" go test $$(go list ./... | grep -v /e2e) -coverprofile cover.out +.PHONY: test-tanka +test-tanka: ## Render and validate the self-contained Tanka environment. + bash hack/test-tanka.sh + +TANKA_ENV ?= tanka/environments/guion + +.PHONY: tanka-show +tanka-show: ## Render the operator Tanka environment. + tk show $(TANKA_ENV) + +.PHONY: tanka-diff +tanka-diff: ## Diff the operator Tanka environment against its cluster. + tk diff $(TANKA_ENV) + +.PHONY: tanka-apply +tanka-apply: ## Apply the operator Tanka environment to its cluster. + tk apply $(TANKA_ENV) + # TODO(user): To use a different vendor for e2e tests, modify the setup under 'tests/e2e'. # The default setup assumes Kind is pre-installed and builds/loads the Manager Docker image locally. # CertManager is installed by default; skip with: diff --git a/README.md b/README.md index 7634b08..851c3a6 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ CloudNative Supabase provides a single `SupabaseProject` Custom Resource that ma - [CloudNativePG operator](https://cloudnative-pg.io/documentation/current/installation_upgrade/) installed - [CNPG Barman Cloud Plugin](https://github.com/cloudnative-pg/plugin-barman-cloud) (for backup/recovery features) - Helm 3.8+ +- [Tanka](https://tanka.dev/install/) (for the repository-owned Guion deployment) - (Optional) [Reloader](https://github.com/stakater/Reloader) - for automatic pod restarts on secret/configmap changes ## Installation @@ -48,6 +49,20 @@ helm install cloudnative-supabase \ The public controller image is available at `ghcr.io/guionai/cloudnative-supabase` and does not require registry credentials. +### Deploy the Guion operator with Tanka + +The self-contained environment in [`tanka/`](tanka/) renders this repository's +chart and CRD without Jsonnet dependencies: + +```bash +make tanka-show +make tanka-diff +make tanka-apply +``` + +This installs only the shared operator in `cnsupa-system`. Application-specific +`SupabaseProject` resources remain owned by their application repositories. + ### Install from source ```bash diff --git a/hack/test-tanka.sh b/hack/test-tanka.sh new file mode 100644 index 0000000..838f2b4 --- /dev/null +++ b/hack/test-tanka.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +rendered="$(mktemp)" +trap 'rm -f "${rendered}"' EXIT + +cd "${repo_root}" + +assert_resource_contains() { + local target="$1" + local expected="$2" + + tk show tanka/environments/guion \ + --dangerous-allow-redirect \ + --target "${target}" >"${rendered}" + grep -Fq -- "${expected}" "${rendered}" +} + +assert_resource_contains Namespace/cnsupa-system 'kind: Namespace' +assert_resource_contains CustomResourceDefinition/supabaseprojects.supabase.guion.dev 'name: supabaseprojects.supabase.guion.dev' +assert_resource_contains ClusterRole/cloudnative-supabase-manager 'name: cloudnative-supabase-manager' +assert_resource_contains Deployment/cloudnative-supabase 'namespace: cnsupa-system' +assert_resource_contains Deployment/cloudnative-supabase 'image: ghcr.io/guionai/cloudnative-supabase:latest' diff --git a/tanka/README.md b/tanka/README.md new file mode 100644 index 0000000..abfe947 --- /dev/null +++ b/tanka/README.md @@ -0,0 +1,18 @@ +# Tanka deployment + +This directory deploys the CloudNative Supabase operator itself. It is +self-contained: the environment renders the Helm chart and CRD from this +repository and has no Jsonnet library dependencies. + +The `guion` environment targets `https://kube-new.flicknote.app` and deploys +the operator into `cnsupa-system` using the public GHCR image. + +```sh +make tanka-show +make tanka-diff +make tanka-apply +``` + +Run `make test-tanka` after changing the chart, CRD, or Jsonnet environment. +Application-specific `SupabaseProject` resources belong in their application +repositories, not in this operator environment. diff --git a/tanka/environments/guion/main.jsonnet b/tanka/environments/guion/main.jsonnet new file mode 100644 index 0000000..80aa19a --- /dev/null +++ b/tanka/environments/guion/main.jsonnet @@ -0,0 +1,29 @@ +local namespace = 'cnsupa-system'; +local chart = std.native('helmTemplate')( + 'cloudnative-supabase', + '../../../charts/cloudnative-supabase', + { + calledFrom: std.thisFile, + namespace: namespace, + values: { + image: { + tag: 'latest', + pullPolicy: 'Always', + }, + }, + }, +); + +{ + namespace: { + apiVersion: 'v1', + kind: 'Namespace', + metadata: { + name: namespace, + labels: { + 'app.kubernetes.io/name': 'cloudnative-supabase', + 'app.kubernetes.io/managed-by': 'tanka', + }, + }, + }, +} + chart diff --git a/tanka/environments/guion/spec.json b/tanka/environments/guion/spec.json new file mode 100644 index 0000000..ddfc80f --- /dev/null +++ b/tanka/environments/guion/spec.json @@ -0,0 +1,11 @@ +{ + "apiVersion": "tanka.dev/v1alpha1", + "kind": "Environment", + "metadata": { + "name": "environments/guion" + }, + "spec": { + "apiServer": "https://kube-new.flicknote.app", + "namespace": "cnsupa-system" + } +} diff --git a/tanka/jsonnetfile.json b/tanka/jsonnetfile.json new file mode 100644 index 0000000..746f075 --- /dev/null +++ b/tanka/jsonnetfile.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "dependencies": [], + "legacyImports": false +} From c5739c39a1e01b73022176339d4d994725d12ab6 Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 22 Jul 2026 15:17:14 +0800 Subject: [PATCH 18/21] fix(operator): address PowerSync deployment review --- .github/workflows/ci.yaml | 2 +- .github/workflows/pr.yaml | 21 +- .github/workflows/release.yaml | 4 +- Makefile | 24 +- README.md | 12 +- .../templates/_helpers.tpl | 5 +- charts/cloudnative-supabase/values.yaml | 2 + go.mod | 2 +- hack/test-delivery.sh | 23 + hack/test-tanka.sh | 5 +- internal/controller/cnpg_roles_test.go | 8 +- .../controller/powersync_lifecycle_test.go | 398 ++++++++++++++++++ .../controller/supabaseproject_controller.go | 304 +++++++++++-- internal/resources/configmaps/powersync.go | 2 +- .../resources/configmaps/powersync_test.go | 3 + internal/resources/deployments/powersync.go | 38 +- .../resources/deployments/powersync_test.go | 57 ++- tanka/README.md | 10 +- tanka/environments/guion/main.jsonnet | 6 +- 19 files changed, 860 insertions(+), 66 deletions(-) create mode 100644 hack/test-delivery.sh create mode 100644 internal/controller/powersync_lifecycle_test.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5ff26ad..8d772a4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -34,7 +34,7 @@ jobs: run: go install github.com/grafana/tanka/cmd/tk@v0.37.4 - name: Run tests - run: make test test-tanka + run: make test test-tanka test-delivery - name: Build run: go build -o bin/manager cmd/main.go diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 1977544..8a3a354 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -28,7 +28,7 @@ jobs: run: go install github.com/grafana/tanka/cmd/tk@v0.37.4 - name: Run tests - run: make test test-tanka + run: make test test-tanka test-delivery - name: Build run: go build -o bin/manager cmd/main.go @@ -55,3 +55,22 @@ jobs: with: version: v2.5.0 args: --timeout=5m + + docker: + runs-on: ubuntu-latest + needs: [build, lint] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build multi-platform image + uses: docker/build-push-action@v6 + with: + context: . + push: false + platforms: linux/amd64,linux/arm64 + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 17c99b1..77e9b1a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -33,7 +33,7 @@ jobs: run: go install github.com/grafana/tanka/cmd/tk@v0.37.4 - name: Run tests - run: make test test-tanka + run: make test test-tanka test-delivery - name: Generate and check manifests run: | @@ -59,7 +59,6 @@ jobs: tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest - name: Build and push uses: docker/build-push-action@v6 @@ -79,6 +78,7 @@ jobs: mkdir -p dist helm package charts/cloudnative-supabase --version "${VERSION}" --app-version "${VERSION}" --destination dist helm push "dist/cloudnative-supabase-${VERSION}.tgz" "${CHART_REGISTRY}" + helm pull "${CHART_REGISTRY}/cloudnative-supabase" --version "${VERSION}" --destination /tmp - name: Create GitHub Release uses: softprops/action-gh-release@v2 diff --git a/Makefile b/Makefile index 65f09dd..04ee15f 100644 --- a/Makefile +++ b/Makefile @@ -65,19 +65,31 @@ test: manifests generate fmt vet setup-envtest ## Run tests. test-tanka: ## Render and validate the self-contained Tanka environment. bash hack/test-tanka.sh +.PHONY: test-delivery +test-delivery: ## Validate release and deployment invariants. + bash hack/test-delivery.sh + TANKA_ENV ?= tanka/environments/guion +TANKA_IMAGE ?= + +.PHONY: require-tanka-image +require-tanka-image: + @printf '%s\n' "$(TANKA_IMAGE)" | grep -Eq '^(sha-[0-9a-f]{40}|[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?)$$' || { \ + echo "TANKA_IMAGE must be a full sha-<40 hex> or semantic-version tag" >&2; \ + exit 1; \ + } .PHONY: tanka-show -tanka-show: ## Render the operator Tanka environment. - tk show $(TANKA_ENV) +tanka-show: require-tanka-image ## Render the operator Tanka environment. + tk show $(TANKA_ENV) --ext-str imageTag=$(TANKA_IMAGE) .PHONY: tanka-diff -tanka-diff: ## Diff the operator Tanka environment against its cluster. - tk diff $(TANKA_ENV) +tanka-diff: require-tanka-image ## Diff the operator Tanka environment against its cluster. + tk diff $(TANKA_ENV) --ext-str imageTag=$(TANKA_IMAGE) .PHONY: tanka-apply -tanka-apply: ## Apply the operator Tanka environment to its cluster. - tk apply $(TANKA_ENV) +tanka-apply: require-tanka-image ## Apply the operator Tanka environment to its cluster. + tk apply $(TANKA_ENV) --ext-str imageTag=$(TANKA_IMAGE) # TODO(user): To use a different vendor for e2e tests, modify the setup under 'tests/e2e'. # The default setup assumes Kind is pre-installed and builds/loads the Manager Docker image locally. diff --git a/README.md b/README.md index 851c3a6..8e4955f 100644 --- a/README.md +++ b/README.md @@ -39,15 +39,19 @@ CloudNative Supabase provides a single `SupabaseProject` Custom Resource that ma ### Install with Helm ```bash +# Replace PUBLISHED_VERSION with a version listed in GitHub Releases. +VERSION="PUBLISHED_VERSION" helm install cloudnative-supabase \ oci://ghcr.io/guionai/charts/cloudnative-supabase \ --namespace cloudnative-supabase-system \ --create-namespace \ - --version 0.1.8 + --version "${VERSION}" ``` The public controller image is available at `ghcr.io/guionai/cloudnative-supabase` and does not require registry credentials. +The OCI chart becomes installable after a tagged release is published and its +GHCR package has been made public. ### Deploy the Guion operator with Tanka @@ -55,9 +59,9 @@ The self-contained environment in [`tanka/`](tanka/) renders this repository's chart and CRD without Jsonnet dependencies: ```bash -make tanka-show -make tanka-diff -make tanka-apply +TANKA_IMAGE=sha-COMMIT make tanka-show +TANKA_IMAGE=sha-COMMIT make tanka-diff +TANKA_IMAGE=sha-COMMIT make tanka-apply ``` This installs only the shared operator in `cnsupa-system`. Application-specific diff --git a/charts/cloudnative-supabase/templates/_helpers.tpl b/charts/cloudnative-supabase/templates/_helpers.tpl index 7d17580..983fb1d 100644 --- a/charts/cloudnative-supabase/templates/_helpers.tpl +++ b/charts/cloudnative-supabase/templates/_helpers.tpl @@ -36,8 +36,9 @@ Common labels {{- define "cloudnative-supabase.labels" -}} helm.sh/chart: {{ include "cloudnative-supabase.chart" . }} {{ include "cloudnative-supabase.selectorLabels" . }} -{{- if .Chart.AppVersion }} -app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- $appVersion := default .Chart.AppVersion .Values.versionOverride }} +{{- if $appVersion }} +app.kubernetes.io/version: {{ $appVersion | quote }} {{- end }} app.kubernetes.io/managed-by: {{ .Release.Service }} {{- end }} diff --git a/charts/cloudnative-supabase/values.yaml b/charts/cloudnative-supabase/values.yaml index 34bdaf5..bcc8d9b 100644 --- a/charts/cloudnative-supabase/values.yaml +++ b/charts/cloudnative-supabase/values.yaml @@ -9,6 +9,8 @@ image: imagePullSecrets: [] nameOverride: "" fullnameOverride: "" +# Overrides app.kubernetes.io/version when deploying an immutable image tag. +versionOverride: "" serviceAccount: # Specifies whether a service account should be created diff --git a/go.mod b/go.mod index b967c5c..3028ae0 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( k8s.io/client-go v0.35.0 k8s.io/utils v0.0.0-20251222233032-718f0e51e6d2 sigs.k8s.io/controller-runtime v0.22.4 + sigs.k8s.io/yaml v1.6.0 ) require ( @@ -115,5 +116,4 @@ require ( sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/hack/test-delivery.sh b/hack/test-delivery.sh new file mode 100644 index 0000000..f00db07 --- /dev/null +++ b/hack/test-delivery.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" + +grep -Fq 'platforms: linux/amd64,linux/arm64' .github/workflows/pr.yaml +if grep -Fq 'type=raw,value=latest' .github/workflows/release.yaml; then + echo 'release workflow must not overwrite the main branch latest tag' >&2 + exit 1 +fi +if grep -Fq -- '--version 0.1.8' README.md; then + echo 'README must not point at an unpublished OCI chart version' >&2 + exit 1 +fi +grep -Fq 'TANKA_IMAGE=' tanka/README.md +grep -Fq "helm pull \"\${CHART_REGISTRY}/cloudnative-supabase\"" .github/workflows/release.yaml + +if TANKA_DANGEROUS_ALLOW_REDIRECT=true make tanka-show TANKA_IMAGE=latest >/dev/null 2>&1; then + echo 'Tanka targets must reject mutable image tags' >&2 + exit 1 +fi diff --git a/hack/test-tanka.sh b/hack/test-tanka.sh index 838f2b4..d30f4c6 100644 --- a/hack/test-tanka.sh +++ b/hack/test-tanka.sh @@ -4,6 +4,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" rendered="$(mktemp)" +image_tag="sha-0000000000000000000000000000000000000000" trap 'rm -f "${rendered}"' EXIT cd "${repo_root}" @@ -14,6 +15,7 @@ assert_resource_contains() { tk show tanka/environments/guion \ --dangerous-allow-redirect \ + --ext-str "imageTag=${image_tag}" \ --target "${target}" >"${rendered}" grep -Fq -- "${expected}" "${rendered}" } @@ -22,4 +24,5 @@ assert_resource_contains Namespace/cnsupa-system 'kind: Namespace' assert_resource_contains CustomResourceDefinition/supabaseprojects.supabase.guion.dev 'name: supabaseprojects.supabase.guion.dev' assert_resource_contains ClusterRole/cloudnative-supabase-manager 'name: cloudnative-supabase-manager' assert_resource_contains Deployment/cloudnative-supabase 'namespace: cnsupa-system' -assert_resource_contains Deployment/cloudnative-supabase 'image: ghcr.io/guionai/cloudnative-supabase:latest' +assert_resource_contains Deployment/cloudnative-supabase "image: ghcr.io/guionai/cloudnative-supabase:${image_tag}" +assert_resource_contains Deployment/cloudnative-supabase "app.kubernetes.io/version: ${image_tag}" diff --git a/internal/controller/cnpg_roles_test.go b/internal/controller/cnpg_roles_test.go index a4e330a..daea9e0 100644 --- a/internal/controller/cnpg_roles_test.go +++ b/internal/controller/cnpg_roles_test.go @@ -4,6 +4,7 @@ import ( "testing" cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" ) @@ -37,9 +38,14 @@ func TestPublicationIsApplied(t *testing.T) { t.Fatal("publication without status must not be ready") } publication := &cnpgv1.Publication{ - Status: cnpgv1.PublicationStatus{Applied: ptr.To(true)}, + ObjectMeta: metav1.ObjectMeta{Generation: 2}, + Status: cnpgv1.PublicationStatus{Applied: ptr.To(true), ObservedGeneration: 2}, } if !publicationIsApplied(publication) { t.Fatal("publication with applied status must be ready") } + publication.Status.ObservedGeneration = 1 + if publicationIsApplied(publication) { + t.Fatal("publication with stale observed generation must not be ready") + } } diff --git a/internal/controller/powersync_lifecycle_test.go b/internal/controller/powersync_lifecycle_test.go new file mode 100644 index 0000000..4a634e2 --- /dev/null +++ b/internal/controller/powersync_lifecycle_test.go @@ -0,0 +1,398 @@ +package controller + +import ( + "context" + "testing" + + cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" + "github.com/GuionAI/cloudnative-supabase/internal/resources/configmaps" + "github.com/GuionAI/cloudnative-supabase/internal/resources/deployments" + "github.com/GuionAI/cloudnative-supabase/internal/resources/jobs" +) + +func TestCleanupPowerSyncDeletesOwnedRuntimeResourcesAndClearsStatus(t *testing.T) { + t.Parallel() + + scheme := newPowerSyncTestScheme(t) + project := &supabasev1alpha1.SupabaseProject{ + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "default", UID: "project-uid"}, + Status: supabasev1alpha1.SupabaseProjectStatus{ + Services: supabasev1alpha1.ServicesStatus{ + PowersyncAPI: supabasev1alpha1.ServiceStatus{Ready: true}, + PowersyncReplication: supabasev1alpha1.ServiceStatus{Ready: true}, + }, + Conditions: []metav1.Condition{ + {Type: supabasev1alpha1.ConditionTypeCDCReady, Status: metav1.ConditionTrue}, + {Type: supabasev1alpha1.ConditionTypePowersyncReady, Status: metav1.ConditionTrue}, + }, + }, + } + owned := []client.Object{ + &appsv1.Deployment{ObjectMeta: ownedMeta(project, deployments.PowersyncAPIDeploymentName(project))}, + &appsv1.Deployment{ObjectMeta: ownedMeta(project, deployments.PowersyncReplicationDeploymentName(project))}, + &corev1.Service{ObjectMeta: ownedMeta(project, project.Name+"-powersync-api")}, + &corev1.ConfigMap{ObjectMeta: ownedMeta(project, configmaps.PowersyncConfigMapName(project))}, + &corev1.ConfigMap{ObjectMeta: ownedMeta(project, configmaps.PowersyncSyncRulesConfigMapName(project))}, + &corev1.ConfigMap{ObjectMeta: ownedMeta(project, jobs.CDCConfigMapName(project))}, + &batchv1.Job{ObjectMeta: ownedMeta(project, jobs.CDCJobName(project))}, + &batchv1.CronJob{ObjectMeta: ownedMeta(project, deployments.PowersyncCompactCronJobName(project))}, + &cnpgv1.Publication{ObjectMeta: ownedMeta(project, project.Name+"-powersync")}, + } + objects := append([]client.Object{project}, owned...) + reconciler := &SupabaseProjectReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(project).WithObjects(objects...).Build(), + Scheme: scheme, + } + + if err := reconciler.cleanupPowerSync(context.Background(), project); err != nil { + t.Fatal(err) + } + for _, object := range owned { + err := reconciler.Get(context.Background(), client.ObjectKeyFromObject(object), object) + if !apierrors.IsNotFound(err) { + t.Fatalf("%T %s was not deleted: %v", object, object.GetName(), err) + } + } + if project.Status.Services.PowersyncAPI.Ready || project.Status.Services.PowersyncReplication.Ready { + t.Fatal("stale PowerSync service status was not cleared") + } + if meta.FindStatusCondition(project.Status.Conditions, supabasev1alpha1.ConditionTypeCDCReady) != nil || + meta.FindStatusCondition(project.Status.Conditions, supabasev1alpha1.ConditionTypePowersyncReady) != nil { + t.Fatal("stale PowerSync conditions were not cleared") + } +} + +func TestPowerSyncStatusNeedsCleanup(t *testing.T) { + t.Parallel() + + project := &supabasev1alpha1.SupabaseProject{} + if powerSyncStatusNeedsCleanup(project) { + t.Fatal("never-enabled PowerSync must not trigger a status update") + } + project.Status.Services.PowersyncAPI.Ready = true + if !powerSyncStatusNeedsCleanup(project) { + t.Fatal("stale service status must trigger cleanup") + } +} + +func TestCleanupPowerSyncCompactDeletesOwnedCronJob(t *testing.T) { + t.Parallel() + + scheme := newPowerSyncTestScheme(t) + project := &supabasev1alpha1.SupabaseProject{ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "default", UID: "project-uid"}} + cronJob := &batchv1.CronJob{ObjectMeta: ownedMeta(project, deployments.PowersyncCompactCronJobName(project))} + reconciler := &SupabaseProjectReconciler{Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(cronJob).Build(), Scheme: scheme} + + if err := reconciler.cleanupPowerSyncCompact(context.Background(), project); err != nil { + t.Fatal(err) + } + if err := reconciler.Get(context.Background(), client.ObjectKeyFromObject(cronJob), cronJob); !apierrors.IsNotFound(err) { + t.Fatalf("compact CronJob was not deleted: %v", err) + } +} + +func newPowerSyncTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + for _, add := range []func(*runtime.Scheme) error{ + corev1.AddToScheme, appsv1.AddToScheme, batchv1.AddToScheme, cnpgv1.AddToScheme, supabasev1alpha1.AddToScheme, + } { + if err := add(scheme); err != nil { + t.Fatal(err) + } + } + return scheme +} + +func ownedMeta(project *supabasev1alpha1.SupabaseProject, name string) metav1.ObjectMeta { + controller := true + return metav1.ObjectMeta{ + Name: name, + Namespace: project.Namespace, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: supabasev1alpha1.GroupVersion.String(), + Kind: "SupabaseProject", + Name: project.Name, + UID: project.UID, + Controller: &controller, + }}, + } +} + +func TestPowersyncDeploymentIsReadyForCurrentGeneration(t *testing.T) { + t.Parallel() + + replicas := int32(2) + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Generation: 3}, + Spec: appsv1.DeploymentSpec{Replicas: &replicas}, + Status: appsv1.DeploymentStatus{ + ObservedGeneration: 3, + ReadyReplicas: 2, + }, + } + if !powersyncDeploymentIsReady(deployment) { + t.Fatal("current rollout with all replicas ready must be ready") + } + deployment.Status.ObservedGeneration = 2 + if powersyncDeploymentIsReady(deployment) { + t.Fatal("stale rollout status must not be ready") + } + deployment.Status.ObservedGeneration = 3 + deployment.Status.ReadyReplicas = 1 + if powersyncDeploymentIsReady(deployment) { + t.Fatal("partial rollout must not be ready") + } +} + +func TestPowerSyncManagedRolesReady(t *testing.T) { + t.Parallel() + + cluster := &cnpgv1.Cluster{Status: cnpgv1.ClusterStatus{ManagedRolesStatus: cnpgv1.ManagedRoles{ + ByStatus: map[cnpgv1.RoleStatus][]string{ + cnpgv1.RoleStatusReconciled: {"supabase_admin", "powersync_storage", "powersync_replication"}, + }, + }}} + ready, err := powerSyncManagedRolesReady(cluster) + if err != nil || !ready { + t.Fatalf("reconciled roles should be ready: ready=%v err=%v", ready, err) + } + cluster.Status.ManagedRolesStatus.ByStatus[cnpgv1.RoleStatusReconciled] = []string{"powersync_storage"} + ready, err = powerSyncManagedRolesReady(cluster) + if err != nil || ready { + t.Fatalf("missing role should be pending: ready=%v err=%v", ready, err) + } + cluster.Status.ManagedRolesStatus.CannotReconcile = map[string][]string{"powersync_replication": {"secret invalid"}} + if _, err := powerSyncManagedRolesReady(cluster); err == nil { + t.Fatal("unreconcilable PowerSync role must return an error") + } +} + +func TestLoadAndValidatePowerSyncRules(t *testing.T) { + t.Parallel() + + scheme := newPowerSyncTestScheme(t) + external := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "rules", Namespace: "default"}, + Data: map[string]string{"sync_rules.yaml": "config:\n edition: 3\nstreams: {}\n"}, + } + reconciler := &SupabaseProjectReconciler{Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(external).Build(), Scheme: scheme} + + tests := []struct { + name string + rules supabasev1alpha1.SyncRulesSpec + wantErr bool + }{ + {name: "inline edition 3", rules: supabasev1alpha1.SyncRulesSpec{Inline: "config:\n edition: 3\nstreams: {}\n"}}, + {name: "inline missing streams", rules: supabasev1alpha1.SyncRulesSpec{Inline: "config:\n edition: 3\n"}, wantErr: true}, + {name: "inline wrong edition", rules: supabasev1alpha1.SyncRulesSpec{Inline: "config:\n edition: 2\nstreams: {}\n"}, wantErr: true}, + {name: "inline malformed", rules: supabasev1alpha1.SyncRulesSpec{Inline: "config: ["}, wantErr: true}, + {name: "external edition 3", rules: supabasev1alpha1.SyncRulesSpec{ConfigMapRef: "rules"}}, + {name: "external missing", rules: supabasev1alpha1.SyncRulesSpec{ConfigMapRef: "missing"}, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + project := &supabasev1alpha1.SupabaseProject{ + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "default"}, + Spec: supabasev1alpha1.SupabaseProjectSpec{Powersync: &supabasev1alpha1.PowersyncSpec{SyncRules: tt.rules}}, + } + _, err := reconciler.loadAndValidatePowerSyncRules(context.Background(), project) + if (err != nil) != tt.wantErr { + t.Fatalf("error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestApplyPowerSyncConfigHashChangesPodTemplate(t *testing.T) { + t.Parallel() + + deploymentA := &appsv1.Deployment{} + applyPowerSyncConfigHash(deploymentA, "config", []byte("rules-a")) + hashA := deploymentA.Spec.Template.Annotations[powerSyncConfigHashAnnotation] + if hashA == "" { + t.Fatal("config hash annotation was not set") + } + deploymentB := &appsv1.Deployment{} + applyPowerSyncConfigHash(deploymentB, "config", []byte("rules-b")) + if hashB := deploymentB.Spec.Template.Annotations[powerSyncConfigHashAnnotation]; hashB == hashA { + t.Fatal("sync rule changes must change the pod template hash") + } +} + +func TestMapExternalPowerSyncConfigMapToProjects(t *testing.T) { + t.Parallel() + + scheme := newPowerSyncTestScheme(t) + matching := &supabasev1alpha1.SupabaseProject{ + ObjectMeta: metav1.ObjectMeta{Name: "matching", Namespace: "default"}, + Spec: supabasev1alpha1.SupabaseProjectSpec{Powersync: &supabasev1alpha1.PowersyncSpec{ + SyncRules: supabasev1alpha1.SyncRulesSpec{ConfigMapRef: "rules"}, + }}, + } + unrelated := &supabasev1alpha1.SupabaseProject{ + ObjectMeta: metav1.ObjectMeta{Name: "unrelated", Namespace: "default"}, + Spec: supabasev1alpha1.SupabaseProjectSpec{Powersync: &supabasev1alpha1.PowersyncSpec{ + SyncRules: supabasev1alpha1.SyncRulesSpec{ConfigMapRef: "other"}, + }}, + } + reconciler := &SupabaseProjectReconciler{Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(matching, unrelated).Build(), Scheme: scheme} + requests := reconciler.mapPowerSyncConfigMapToProjects(context.Background(), &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "rules", Namespace: "default"}}) + if len(requests) != 1 || requests[0].Name != "matching" || requests[0].Namespace != "default" { + t.Fatalf("requests = %#v, want only default/matching", requests) + } +} + +func TestReconcilePowerSyncPublicationRepairsSpecAndOwnership(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + if err := cnpgv1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := supabasev1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + project := &supabasev1alpha1.SupabaseProject{ + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "default", UID: "project-uid"}, + Spec: supabasev1alpha1.SupabaseProjectSpec{Powersync: &supabasev1alpha1.PowersyncSpec{}}, + } + publication := &cnpgv1.Publication{ + ObjectMeta: metav1.ObjectMeta{Name: "app-powersync", Namespace: "default"}, + Spec: cnpgv1.PublicationSpec{ + ClusterRef: corev1.LocalObjectReference{Name: "app-pg"}, + Name: "powersync", + DBName: "supabase", + Target: cnpgv1.PublicationTarget{Objects: []cnpgv1.PublicationTargetObject{{TablesInSchema: "wrong"}}}, + ReclaimPolicy: cnpgv1.PublicationReclaimRetain, + }, + Status: cnpgv1.PublicationStatus{Applied: ptr.To(true)}, + } + reconciler := &SupabaseProjectReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(project, publication).Build(), + Scheme: scheme, + } + + if _, err := reconciler.reconcilePowerSyncPublication(context.Background(), project); err != nil { + t.Fatal(err) + } + updated := &cnpgv1.Publication{} + if err := reconciler.Get(context.Background(), client.ObjectKeyFromObject(publication), updated); err != nil { + t.Fatal(err) + } + if updated.Spec.Target.Objects[0].TablesInSchema != "public" || updated.Spec.ReclaimPolicy != cnpgv1.PublicationReclaimDelete { + t.Fatalf("publication spec was not repaired: %#v", updated.Spec) + } + if !metav1.IsControlledBy(updated, project) { + t.Fatal("publication owner reference was not repaired") + } +} + +func TestCreateOrCheckJobWaitsDuringRetryBackoff(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + if err := batchv1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := supabasev1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + + project := &supabasev1alpha1.SupabaseProject{ + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "default", UID: "project-uid"}, + } + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-cdc-permissions", + Namespace: "default", + Annotations: map[string]string{cdcScriptHashAnnotation: "same"}, + }, + Status: batchv1.JobStatus{Failed: 1}, + } + reconciler := &SupabaseProjectReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(job).Build(), + Scheme: scheme, + } + + completed, err := reconciler.createOrCheckJob(context.Background(), project, job.DeepCopy(), "same") + if err != nil { + t.Fatalf("retrying Job must not be terminal: %v", err) + } + if completed { + t.Fatal("retrying Job must not be complete") + } +} + +func TestSetConditionRecordsObservedGeneration(t *testing.T) { + t.Parallel() + + project := &supabasev1alpha1.SupabaseProject{ObjectMeta: metav1.ObjectMeta{Generation: 7}} + reconciler := &SupabaseProjectReconciler{} + reconciler.setCondition(project, supabasev1alpha1.ConditionTypePowersyncReady, metav1.ConditionFalse, "Pending", "waiting") + if got := project.Status.Conditions[0].ObservedGeneration; got != 7 { + t.Fatalf("observed generation = %d, want 7", got) + } +} + +func TestCreateOrCheckJobUsesTerminalConditions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + conditionType batchv1.JobConditionType + wantComplete bool + wantError bool + }{ + {name: "complete", conditionType: batchv1.JobComplete, wantComplete: true}, + {name: "failed", conditionType: batchv1.JobFailed, wantError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + if err := batchv1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := supabasev1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + + project := &supabasev1alpha1.SupabaseProject{ + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "default", UID: "project-uid"}, + } + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-cdc-permissions", + Namespace: "default", + Annotations: map[string]string{cdcScriptHashAnnotation: "same"}, + }, + Status: batchv1.JobStatus{Conditions: []batchv1.JobCondition{{Type: tt.conditionType, Status: "True"}}}, + } + reconciler := &SupabaseProjectReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(job).Build(), + Scheme: scheme, + } + + completed, err := reconciler.createOrCheckJob(context.Background(), project, job.DeepCopy(), "same") + if (err != nil) != tt.wantError { + t.Fatalf("error = %v, wantError %v", err, tt.wantError) + } + if completed != tt.wantComplete { + t.Fatalf("completed = %v, want %v", completed, tt.wantComplete) + } + }) + } +} diff --git a/internal/controller/supabaseproject_controller.go b/internal/controller/supabaseproject_controller.go index 6961c8d..d11867b 100644 --- a/internal/controller/supabaseproject_controller.go +++ b/internal/controller/supabaseproject_controller.go @@ -37,7 +37,10 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/yaml" supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" "github.com/GuionAI/cloudnative-supabase/internal/resources/cnpg" @@ -156,15 +159,24 @@ func (r *SupabaseProjectReconciler) Reconcile(ctx context.Context, req ctrl.Requ // Phase 6: PowerSync (after core services) if project.Spec.Powersync != nil { + syncRules, err := r.reconcilePowerSyncRulesValidation(ctx, project) + if err != nil { + return ctrl.Result{}, err + } + if result, err := r.waitForPowerSyncManagedRoles(ctx, project); err != nil || result.RequeueAfter > 0 { + return result, err + } if result, err := r.reconcilePowerSyncPublication(ctx, project); err != nil || result.RequeueAfter > 0 { return result, err } if result, err := r.reconcileCDCPermissions(ctx, project); err != nil || result.RequeueAfter > 0 { return result, err } - if err := r.reconcilePowersync(ctx, project); err != nil { - return ctrl.Result{}, err + if result, err := r.reconcilePowersync(ctx, project, syncRules); err != nil || result.RequeueAfter > 0 { + return result, err } + } else if err := r.cleanupPowerSync(ctx, project); err != nil { + return ctrl.Result{}, err } // All phases complete @@ -180,6 +192,88 @@ func (r *SupabaseProjectReconciler) Reconcile(ctx context.Context, req ctrl.Requ return ctrl.Result{}, nil } +func (r *SupabaseProjectReconciler) reconcilePowerSyncRulesValidation(ctx context.Context, project *supabasev1alpha1.SupabaseProject) ([]byte, error) { + rules, err := r.loadAndValidatePowerSyncRules(ctx, project) + if err == nil { + return rules, nil + } + r.setCondition(project, supabasev1alpha1.ConditionTypePowersyncReady, metav1.ConditionFalse, "InvalidSyncRules", err.Error()) + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return nil, statusErr + } + return nil, err +} + +func (r *SupabaseProjectReconciler) loadAndValidatePowerSyncRules(ctx context.Context, project *supabasev1alpha1.SupabaseProject) ([]byte, error) { + rules := []byte(project.Spec.Powersync.SyncRules.Inline) + if ref := project.Spec.Powersync.SyncRules.ConfigMapRef; ref != "" { + configMap := &corev1.ConfigMap{} + if err := r.Get(ctx, types.NamespacedName{Name: ref, Namespace: project.Namespace}, configMap); err != nil { + return nil, fmt.Errorf("getting PowerSync sync rules ConfigMap %s: %w", ref, err) + } + content, ok := configMap.Data["sync_rules.yaml"] + if !ok || content == "" { + return nil, fmt.Errorf("PowerSync sync rules ConfigMap %s must contain non-empty sync_rules.yaml", ref) + } + rules = []byte(content) + } + + var document struct { + Config struct { + Edition int `json:"edition"` + } `json:"config"` + Streams map[string]any `json:"streams"` + } + if err := yaml.Unmarshal(rules, &document); err != nil { + return nil, fmt.Errorf("parsing PowerSync sync_rules.yaml: %w", err) + } + if document.Config.Edition != 3 { + return nil, fmt.Errorf("PowerSync sync_rules.yaml requires config.edition: 3") + } + if document.Streams == nil { + return nil, fmt.Errorf("PowerSync sync_rules.yaml requires streams") + } + return rules, nil +} + +func (r *SupabaseProjectReconciler) waitForPowerSyncManagedRoles(ctx context.Context, project *supabasev1alpha1.SupabaseProject) (ctrl.Result, error) { + cluster := &cnpgv1.Cluster{} + if err := r.Get(ctx, types.NamespacedName{Name: cnpg.ClusterName(project), Namespace: project.Namespace}, cluster); err != nil { + return ctrl.Result{}, err + } + ready, err := powerSyncManagedRolesReady(cluster) + if err != nil { + r.setCondition(project, supabasev1alpha1.ConditionTypeCDCReady, metav1.ConditionFalse, "ManagedRolesFailed", err.Error()) + if statusErr := r.Status().Update(ctx, project); statusErr != nil { + return ctrl.Result{}, statusErr + } + return ctrl.Result{}, err + } + if !ready { + r.setCondition(project, supabasev1alpha1.ConditionTypeCDCReady, metav1.ConditionFalse, "ManagedRolesPending", "Waiting for CloudNativePG to reconcile PowerSync roles") + if err := r.Status().Update(ctx, project); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: RequeueDelay}, nil + } + return ctrl.Result{}, nil +} + +func powerSyncManagedRolesReady(cluster *cnpgv1.Cluster) (bool, error) { + for _, role := range []string{"powersync_storage", "powersync_replication"} { + if reasons := cluster.Status.ManagedRolesStatus.CannotReconcile[role]; len(reasons) > 0 { + return false, fmt.Errorf("CloudNativePG cannot reconcile role %s: %v", role, reasons) + } + } + reconciled := make(map[string]struct{}) + for _, role := range cluster.Status.ManagedRolesStatus.ByStatus[cnpgv1.RoleStatusReconciled] { + reconciled[role] = struct{}{} + } + _, storageReady := reconciled["powersync_storage"] + _, replicationReady := reconciled["powersync_replication"] + return storageReady && replicationReady, nil +} + // reconcileSecrets ensures all required secrets exist func (r *SupabaseProjectReconciler) reconcileSecrets(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { log := logf.FromContext(ctx) @@ -1122,6 +1216,21 @@ func (r *SupabaseProjectReconciler) reconcilePowerSyncPublication(ctx context.Co return ctrl.Result{RequeueAfter: RequeueDelay}, nil } + before := existing.DeepCopy() + existing.Spec = desired.Spec + existing.Labels = desired.Labels + if err := controllerutil.SetControllerReference(project, existing, r.Scheme); err != nil { + return ctrl.Result{}, err + } + if !apiequality.Semantic.DeepEqual(before.Spec, existing.Spec) || + !apiequality.Semantic.DeepEqual(before.Labels, existing.Labels) || + !apiequality.Semantic.DeepEqual(before.OwnerReferences, existing.OwnerReferences) { + if err := r.Update(ctx, existing); err != nil { + return ctrl.Result{}, fmt.Errorf("updating PowerSync publication: %w", err) + } + return ctrl.Result{RequeueAfter: RequeueDelay}, nil + } + if !publicationIsApplied(existing) { message := "Waiting for the PowerSync publication" if existing.Status.Message != "" { @@ -1138,11 +1247,12 @@ func (r *SupabaseProjectReconciler) reconcilePowerSyncPublication(ctx context.Co } func publicationIsApplied(publication *cnpgv1.Publication) bool { - return publication.Status.Applied != nil && *publication.Status.Applied + return publication.Status.ObservedGeneration == publication.Generation && + publication.Status.Applied != nil && *publication.Status.Applied } // reconcilePowersync deploys the Powersync service (API + Replication + ConfigMaps + CronJob) -func (r *SupabaseProjectReconciler) reconcilePowersync(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { +func (r *SupabaseProjectReconciler) reconcilePowersync(ctx context.Context, project *supabasev1alpha1.SupabaseProject, syncRulesContent []byte) (ctrl.Result, error) { log := logf.FromContext(ctx) log.Info("Reconciling Powersync service") @@ -1153,31 +1263,32 @@ func (r *SupabaseProjectReconciler) reconcilePowersync(ctx context.Context, proj if err := r.createOrUpdateConfigMap(ctx, project, psConfig); err != nil { r.setCondition(project, supabasev1alpha1.ConditionTypePowersyncReady, metav1.ConditionFalse, "ConfigMapFailed", err.Error()) if statusErr := r.Status().Update(ctx, project); statusErr != nil { - return statusErr + return ctrl.Result{}, statusErr } - return err + return ctrl.Result{}, err } // Create sync rules ConfigMap (may be nil if external ConfigMapRef is used) - syncRules := configmaps.BuildPowersyncSyncRulesConfigMap(project) - if syncRules != nil { - if err := r.createOrUpdateConfigMap(ctx, project, syncRules); err != nil { + syncRulesConfigMap := configmaps.BuildPowersyncSyncRulesConfigMap(project) + if syncRulesConfigMap != nil { + if err := r.createOrUpdateConfigMap(ctx, project, syncRulesConfigMap); err != nil { r.setCondition(project, supabasev1alpha1.ConditionTypePowersyncReady, metav1.ConditionFalse, "SyncRulesConfigMapFailed", err.Error()) if statusErr := r.Status().Update(ctx, project); statusErr != nil { - return statusErr + return ctrl.Result{}, statusErr } - return err + return ctrl.Result{}, err } } // Deploy Powersync API apiDeployment := deployments.BuildPowersyncAPIDeployment(project, secretNames) + applyPowerSyncConfigHash(apiDeployment, psConfig.Data["config.json"], syncRulesContent) if err := r.createOrUpdateDeployment(ctx, project, apiDeployment); err != nil { r.setCondition(project, supabasev1alpha1.ConditionTypePowersyncReady, metav1.ConditionFalse, "APIDeploymentFailed", err.Error()) if statusErr := r.Status().Update(ctx, project); statusErr != nil { - return statusErr + return ctrl.Result{}, statusErr } - return err + return ctrl.Result{}, err } // Create Powersync API service @@ -1185,19 +1296,20 @@ func (r *SupabaseProjectReconciler) reconcilePowersync(ctx context.Context, proj if err := r.createOrUpdateService(ctx, project, apiService); err != nil { r.setCondition(project, supabasev1alpha1.ConditionTypePowersyncReady, metav1.ConditionFalse, "APIServiceFailed", err.Error()) if statusErr := r.Status().Update(ctx, project); statusErr != nil { - return statusErr + return ctrl.Result{}, statusErr } - return err + return ctrl.Result{}, err } // Deploy Powersync Replication replDeployment := deployments.BuildPowersyncReplicationDeployment(project, secretNames) + applyPowerSyncConfigHash(replDeployment, psConfig.Data["config.json"], syncRulesContent) if err := r.createOrUpdateDeployment(ctx, project, replDeployment); err != nil { r.setCondition(project, supabasev1alpha1.ConditionTypePowersyncReady, metav1.ConditionFalse, "ReplicationDeploymentFailed", err.Error()) if statusErr := r.Status().Update(ctx, project); statusErr != nil { - return statusErr + return ctrl.Result{}, statusErr } - return err + return ctrl.Result{}, err } // Deploy Powersync Compact CronJob @@ -1206,16 +1318,126 @@ func (r *SupabaseProjectReconciler) reconcilePowersync(ctx context.Context, proj if err := r.createOrUpdateCronJob(ctx, project, compactCronJob); err != nil { r.setCondition(project, supabasev1alpha1.ConditionTypePowersyncReady, metav1.ConditionFalse, "CronJobFailed", err.Error()) if statusErr := r.Status().Update(ctx, project); statusErr != nil { - return statusErr + return ctrl.Result{}, statusErr } - return err + return ctrl.Result{}, err } + } else if err := r.cleanupPowerSyncCompact(ctx, project); err != nil { + return ctrl.Result{}, err + } + + apiReady, apiAvailable, err := r.powersyncDeploymentStatus(ctx, apiDeployment) + if err != nil { + return ctrl.Result{}, err + } + replicationReady, replicationAvailable, err := r.powersyncDeploymentStatus(ctx, replDeployment) + if err != nil { + return ctrl.Result{}, err + } + project.Status.Services.PowersyncAPI = supabasev1alpha1.ServiceStatus{Ready: apiReady, AvailableReplicas: apiAvailable} + project.Status.Services.PowersyncReplication = supabasev1alpha1.ServiceStatus{Ready: replicationReady, AvailableReplicas: replicationAvailable} + if !apiReady || !replicationReady { + r.setCondition(project, supabasev1alpha1.ConditionTypePowersyncReady, metav1.ConditionFalse, "DeploymentsPending", "Waiting for PowerSync deployments to become ready") + if err := r.Status().Update(ctx, project); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: RequeueDelay}, nil } - project.Status.Services.PowersyncAPI = supabasev1alpha1.ServiceStatus{Ready: true} - project.Status.Services.PowersyncReplication = supabasev1alpha1.ServiceStatus{Ready: true} r.setCondition(project, supabasev1alpha1.ConditionTypePowersyncReady, metav1.ConditionTrue, "Ready", "Powersync service is running") - return nil + return ctrl.Result{}, nil +} + +const powerSyncConfigHashAnnotation = "supabase.guion.dev/powersync-config-hash" + +func applyPowerSyncConfigHash(deployment *appsv1.Deployment, config string, syncRules []byte) { + hash := sha256.New() + _, _ = hash.Write([]byte(config)) + _, _ = hash.Write([]byte{0}) + _, _ = hash.Write(syncRules) + if deployment.Spec.Template.Annotations == nil { + deployment.Spec.Template.Annotations = make(map[string]string) + } + deployment.Spec.Template.Annotations[powerSyncConfigHashAnnotation] = hex.EncodeToString(hash.Sum(nil)) +} + +// cleanupPowerSync removes operator-owned runtime resources when PowerSync is +// disabled. Database roles, generated credentials, and PowerSync's internal +// database data are deliberately retained; deleting those requires an explicit +// data-retention policy. +func (r *SupabaseProjectReconciler) cleanupPowerSync(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { + resources := []client.Object{ + &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: deployments.PowersyncAPIDeploymentName(project), Namespace: project.Namespace}}, + &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: deployments.PowersyncReplicationDeploymentName(project), Namespace: project.Namespace}}, + &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: project.Name + "-powersync-api", Namespace: project.Namespace}}, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: configmaps.PowersyncConfigMapName(project), Namespace: project.Namespace}}, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: configmaps.PowersyncSyncRulesConfigMapName(project), Namespace: project.Namespace}}, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: jobs.CDCConfigMapName(project), Namespace: project.Namespace}}, + &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: jobs.CDCJobName(project), Namespace: project.Namespace}}, + &batchv1.CronJob{ObjectMeta: metav1.ObjectMeta{Name: deployments.PowersyncCompactCronJobName(project), Namespace: project.Namespace}}, + &cnpgv1.Publication{ObjectMeta: metav1.ObjectMeta{Name: project.Name + "-powersync", Namespace: project.Namespace}}, + } + for _, resource := range resources { + if err := r.deletePowerSyncOwnedResource(ctx, project, resource); err != nil { + return err + } + } + + if !powerSyncStatusNeedsCleanup(project) { + return nil + } + project.Status.Services.PowersyncAPI = supabasev1alpha1.ServiceStatus{} + project.Status.Services.PowersyncReplication = supabasev1alpha1.ServiceStatus{} + meta.RemoveStatusCondition(&project.Status.Conditions, supabasev1alpha1.ConditionTypeCDCReady) + meta.RemoveStatusCondition(&project.Status.Conditions, supabasev1alpha1.ConditionTypePowersyncReady) + return r.Status().Update(ctx, project) +} + +func powerSyncStatusNeedsCleanup(project *supabasev1alpha1.SupabaseProject) bool { + if !apiequality.Semantic.DeepEqual(project.Status.Services.PowersyncAPI, supabasev1alpha1.ServiceStatus{}) || + !apiequality.Semantic.DeepEqual(project.Status.Services.PowersyncReplication, supabasev1alpha1.ServiceStatus{}) { + return true + } + return meta.FindStatusCondition(project.Status.Conditions, supabasev1alpha1.ConditionTypeCDCReady) != nil || + meta.FindStatusCondition(project.Status.Conditions, supabasev1alpha1.ConditionTypePowersyncReady) != nil +} + +func (r *SupabaseProjectReconciler) cleanupPowerSyncCompact(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { + cronJob := &batchv1.CronJob{ObjectMeta: metav1.ObjectMeta{ + Name: deployments.PowersyncCompactCronJobName(project), + Namespace: project.Namespace, + }} + return r.deletePowerSyncOwnedResource(ctx, project, cronJob) +} + +func (r *SupabaseProjectReconciler) deletePowerSyncOwnedResource(ctx context.Context, project *supabasev1alpha1.SupabaseProject, resource client.Object) error { + if err := r.Get(ctx, client.ObjectKeyFromObject(resource), resource); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return err + } + if !metav1.IsControlledBy(resource, project) { + return nil + } + return client.IgnoreNotFound(r.Delete(ctx, resource)) +} + +func (r *SupabaseProjectReconciler) powersyncDeploymentStatus(ctx context.Context, desired *appsv1.Deployment) (bool, int32, error) { + existing := &appsv1.Deployment{} + if err := r.Get(ctx, types.NamespacedName{Name: desired.Name, Namespace: desired.Namespace}, existing); err != nil { + return false, 0, err + } + return powersyncDeploymentIsReady(existing), existing.Status.AvailableReplicas, nil +} + +func powersyncDeploymentIsReady(deployment *appsv1.Deployment) bool { + expected := int32(1) + if deployment.Spec.Replicas != nil { + expected = *deployment.Spec.Replicas + } + return deployment.Status.ObservedGeneration == deployment.Generation && + deployment.Status.ReadyReplicas == expected } // createOrUpdateCronJob creates or updates a CronJob resource @@ -1288,13 +1510,18 @@ func (r *SupabaseProjectReconciler) createOrCheckJob(ctx context.Context, projec return false, nil } - // Job exists with matching hash — check completion status - if existing.Status.Succeeded > 0 { - log.V(1).Info("Job completed successfully", "name", job.Name) - return true, nil - } - if existing.Status.Failed > 0 && existing.Status.Active == 0 { - return false, fmt.Errorf("job %s has failed", job.Name) + // Job exists with matching hash — only terminal conditions are authoritative. + for _, condition := range existing.Status.Conditions { + if condition.Status != corev1.ConditionTrue { + continue + } + switch condition.Type { + case batchv1.JobComplete: + log.V(1).Info("Job completed successfully", "name", job.Name) + return true, nil + case batchv1.JobFailed: + return false, fmt.Errorf("job %s has failed: %s", job.Name, condition.Message) + } } // Job still running @@ -1306,6 +1533,7 @@ func (r *SupabaseProjectReconciler) setCondition(project *supabasev1alpha1.Supab condition := metav1.Condition{ Type: conditionType, Status: status, + ObservedGeneration: project.Generation, LastTransitionTime: metav1.Now(), Reason: reason, Message: message, @@ -1313,12 +1541,30 @@ func (r *SupabaseProjectReconciler) setCondition(project *supabasev1alpha1.Supab meta.SetStatusCondition(&project.Status.Conditions, condition) } +func (r *SupabaseProjectReconciler) mapPowerSyncConfigMapToProjects(ctx context.Context, object client.Object) []reconcile.Request { + projects := &supabasev1alpha1.SupabaseProjectList{} + if err := r.List(ctx, projects, client.InNamespace(object.GetNamespace())); err != nil { + logf.FromContext(ctx).Error(err, "listing SupabaseProjects for PowerSync ConfigMap", "configMap", object.GetName()) + return nil + } + requests := make([]reconcile.Request, 0) + for i := range projects.Items { + project := &projects.Items[i] + if project.Spec.Powersync == nil || project.Spec.Powersync.SyncRules.ConfigMapRef != object.GetName() { + continue + } + requests = append(requests, reconcile.Request{NamespacedName: types.NamespacedName{Name: project.Name, Namespace: project.Namespace}}) + } + return requests +} + // SetupWithManager sets up the controller with the Manager. func (r *SupabaseProjectReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&supabasev1alpha1.SupabaseProject{}). Owns(&corev1.Secret{}). Owns(&corev1.ConfigMap{}). + Watches(&corev1.ConfigMap{}, handler.EnqueueRequestsFromMapFunc(r.mapPowerSyncConfigMapToProjects)). Owns(&corev1.Service{}). Owns(&appsv1.Deployment{}). Owns(&batchv1.Job{}). diff --git a/internal/resources/configmaps/powersync.go b/internal/resources/configmaps/powersync.go index d0b3ac2..d0300cb 100644 --- a/internal/resources/configmaps/powersync.go +++ b/internal/resources/configmaps/powersync.go @@ -119,7 +119,7 @@ func BuildPowersyncConfigMap(project *supabasev1alpha1.SupabaseProject) *corev1. Port: 8080, SyncRules: powersyncSyncRules{ Path: "/powersync/sync_rules/sync_rules.yaml", - ExitOnError: false, + ExitOnError: true, }, Telemetry: powersyncTelemetry{DisableTelemetrySharing: false}, } diff --git a/internal/resources/configmaps/powersync_test.go b/internal/resources/configmaps/powersync_test.go index 1c84197..5751f11 100644 --- a/internal/resources/configmaps/powersync_test.go +++ b/internal/resources/configmaps/powersync_test.go @@ -135,6 +135,9 @@ func TestBuildPowersyncConfigMap(t *testing.T) { if config.SyncRules.Path != "/powersync/sync_rules/sync_rules.yaml" { t.Errorf("sync rules path = %q", config.SyncRules.Path) } + if !config.SyncRules.ExitOnError { + t.Error("sync rules must fail startup when invalid") + } } func TestBuildPowersyncSyncRulesConfigMap_UsesSyncStreams(t *testing.T) { diff --git a/internal/resources/deployments/powersync.go b/internal/resources/deployments/powersync.go index e75e052..698c1c0 100644 --- a/internal/resources/deployments/powersync.go +++ b/internal/resources/deployments/powersync.go @@ -83,6 +83,20 @@ func DefaultPowersyncReplicationResources() corev1.ResourceRequirements { } } +// DefaultPowersyncCompactResources returns default resource requirements for Powersync compaction. +func DefaultPowersyncCompactResources() corev1.ResourceRequirements { + return corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("256Mi"), + corev1.ResourceCPU: resource.MustParse("100m"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("1Gi"), + corev1.ResourceCPU: resource.MustParse("1"), + }, + } +} + // BuildPowersyncAPIDeployment creates the Powersync API deployment (client-facing) func BuildPowersyncAPIDeployment(project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) *appsv1.Deployment { spec := project.Spec.Powersync @@ -136,7 +150,7 @@ func BuildPowersyncAPIDeployment(project *supabasev1alpha1.SupabaseProject, secr Protocol: corev1.ProtocolTCP, }, }, - LivenessProbe: powersyncFileProbe("/app/.probes/poll", 5, 10, 30), + LivenessProbe: powersyncLivenessProbe(), ReadinessProbe: powersyncFileProbe("/app/.probes/ready", 5, 10, 30), StartupProbe: powersyncFileProbe("/app/.probes/startup", 200, 1, 1), Lifecycle: powersyncLifecycle(), @@ -202,7 +216,7 @@ func BuildPowersyncReplicationDeployment(project *supabasev1alpha1.SupabaseProje Protocol: corev1.ProtocolTCP, }, }, - LivenessProbe: powersyncFileProbe("/app/.probes/poll", 5, 10, 30), + LivenessProbe: powersyncLivenessProbe(), ReadinessProbe: powersyncFileProbe("/app/.probes/ready", 5, 10, 30), StartupProbe: powersyncFileProbe("/app/.probes/startup", 200, 1, 1), Lifecycle: powersyncLifecycle(), @@ -231,14 +245,14 @@ func BuildPowersyncCompactCronJob(project *supabasev1alpha1.SupabaseProject, sec name := PowersyncCompactCronJobName(project) image := ResolveImage(spec.Image, defaults.PowersyncImage, defaults.PowersyncTag) pullPolicy := ResolvePullPolicy(spec.Image) - resources := normalizePowersyncResources(spec.Compact.Resources, DefaultPowersyncAPIResources()) + resources := normalizePowersyncResources(spec.Compact.Resources, DefaultPowersyncCompactResources()) schedule := spec.Compact.Schedule if schedule == "" { schedule = "0 3 * * *" } - env := buildPowersyncEnv(project, secretNames, "--max-old-space-size=330") + env := buildPowersyncEnv(project, secretNames, "--max-old-space-size=512") cronJob := &batchv1.CronJob{ ObjectMeta: metav1.ObjectMeta{ @@ -296,6 +310,22 @@ func powersyncFileProbe(path string, failureThreshold, periodSeconds, timeoutSec } } +func powersyncLivenessProbe() *corev1.Probe { + return &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + Exec: &corev1.ExecAction{Command: []string{ + "sh", + "-ec", + `age=$(( $(date +%s) - $(stat -c %Y /app/.probes/poll) )); [ "$age" -lt 10 ]`, + }}, + }, + FailureThreshold: 5, + InitialDelaySeconds: 5, + PeriodSeconds: 10, + TimeoutSeconds: 30, + } +} + func powersyncLifecycle() *corev1.Lifecycle { return &corev1.Lifecycle{ PreStop: &corev1.LifecycleHandler{ diff --git a/internal/resources/deployments/powersync_test.go b/internal/resources/deployments/powersync_test.go index d84c727..dbfb9b1 100644 --- a/internal/resources/deployments/powersync_test.go +++ b/internal/resources/deployments/powersync_test.go @@ -1,6 +1,7 @@ package deployments import ( + "slices" "testing" supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" @@ -88,9 +89,7 @@ func TestBuildPowersyncAPIDeployment(t *testing.T) { } // PowerSync 1.20 filesystem probes. - if c.LivenessProbe == nil || c.LivenessProbe.Exec == nil || c.LivenessProbe.Exec.Command[1] != "/app/.probes/poll" { - t.Error("expected filesystem liveness probe") - } + assertFreshPowersyncLivenessProbe(t, c.LivenessProbe) if c.ReadinessProbe == nil || c.ReadinessProbe.Exec == nil || c.ReadinessProbe.Exec.Command[1] != "/app/.probes/ready" { t.Error("expected filesystem readiness probe") } @@ -128,17 +127,21 @@ func TestBuildPowersyncAPIDeployment_CustomReplicas(t *testing.T) { } func TestBuildPowersyncAPIDeployment_CustomNodeOptions(t *testing.T) { + const ( + nodeOptionsName = "NODE_OPTIONS" + nodeOptionsValue = "--max-old-space-size=512" + ) project := newTestProject("default") - project.Spec.Powersync.API.NodeOptions = "--max-old-space-size=512" + project.Spec.Powersync.API.NodeOptions = nodeOptionsValue secretNames := newTestSecretNames() dep := BuildPowersyncAPIDeployment(project, secretNames) env := dep.Spec.Template.Spec.Containers[0].Env for _, e := range env { - if e.Name == "NODE_OPTIONS" { - if e.Value != "--max-old-space-size=512" { - t.Errorf("NODE_OPTIONS = %q, want --max-old-space-size=512", e.Value) + if e.Name == nodeOptionsName { + if e.Value != nodeOptionsValue { + t.Errorf("NODE_OPTIONS = %q, want %s", e.Value, nodeOptionsValue) } return } @@ -171,6 +174,7 @@ func TestBuildPowersyncReplicationDeployment(t *testing.T) { if len(c.Ports) != 1 || c.Ports[0].ContainerPort != PowersyncMetricsPort { t.Errorf("expected only metrics port %d", PowersyncMetricsPort) } + assertFreshPowersyncLivenessProbe(t, c.LivenessProbe) // Default NODE_OPTIONS for replication for _, e := range c.Env { @@ -184,6 +188,21 @@ func TestBuildPowersyncReplicationDeployment(t *testing.T) { t.Error("NODE_OPTIONS env var not found") } +func assertFreshPowersyncLivenessProbe(t *testing.T, probe *corev1.Probe) { + t.Helper() + want := []string{ + "sh", + "-ec", + `age=$(( $(date +%s) - $(stat -c %Y /app/.probes/poll) )); [ "$age" -lt 10 ]`, + } + if probe == nil || probe.Exec == nil { + t.Fatal("expected exec liveness probe") + } + if !slices.Equal(probe.Exec.Command, want) { + t.Errorf("liveness command = %v, want %v", probe.Exec.Command, want) + } +} + func TestBuildPowersyncCompactCronJob(t *testing.T) { project := newTestProject(testNamespace) secretNames := newTestSecretNames() @@ -210,6 +229,30 @@ func TestBuildPowersyncCompactCronJob(t *testing.T) { if cj.Spec.ConcurrencyPolicy != "Forbid" { t.Errorf("ConcurrencyPolicy = %q, want Forbid", cj.Spec.ConcurrencyPolicy) } + + resources := c.Resources + if got := resources.Requests.Memory().String(); got != "256Mi" { + t.Errorf("memory request = %q, want 256Mi", got) + } + if got := resources.Requests.Cpu().String(); got != "100m" { + t.Errorf("CPU request = %q, want 100m", got) + } + if got := resources.Limits.Memory().String(); got != "1Gi" { + t.Errorf("memory limit = %q, want 1Gi", got) + } + if got := resources.Limits.Cpu().String(); got != "1" { + t.Errorf("CPU limit = %q, want 1", got) + } + + for _, env := range c.Env { + if env.Name == "NODE_OPTIONS" { + if env.Value != "--max-old-space-size=512" { + t.Errorf("NODE_OPTIONS = %q, want --max-old-space-size=512", env.Value) + } + return + } + } + t.Error("NODE_OPTIONS env var not found") } func TestBuildPowersyncCompactCronJob_CustomSchedule(t *testing.T) { diff --git a/tanka/README.md b/tanka/README.md index abfe947..d397b1d 100644 --- a/tanka/README.md +++ b/tanka/README.md @@ -5,12 +5,14 @@ self-contained: the environment renders the Helm chart and CRD from this repository and has no Jsonnet library dependencies. The `guion` environment targets `https://kube-new.flicknote.app` and deploys -the operator into `cnsupa-system` using the public GHCR image. +the operator into `cnsupa-system` using the public GHCR image. Supply an +immutable release or `sha-...` image tag so each change produces a real rollout +and can be reproduced or rolled back. ```sh -make tanka-show -make tanka-diff -make tanka-apply +TANKA_IMAGE=sha-COMMIT make tanka-show +TANKA_IMAGE=sha-COMMIT make tanka-diff +TANKA_IMAGE=sha-COMMIT make tanka-apply ``` Run `make test-tanka` after changing the chart, CRD, or Jsonnet environment. diff --git a/tanka/environments/guion/main.jsonnet b/tanka/environments/guion/main.jsonnet index 80aa19a..87a4bd6 100644 --- a/tanka/environments/guion/main.jsonnet +++ b/tanka/environments/guion/main.jsonnet @@ -1,4 +1,5 @@ local namespace = 'cnsupa-system'; +local imageTag = std.extVar('imageTag'); local chart = std.native('helmTemplate')( 'cloudnative-supabase', '../../../charts/cloudnative-supabase', @@ -6,9 +7,10 @@ local chart = std.native('helmTemplate')( calledFrom: std.thisFile, namespace: namespace, values: { + versionOverride: imageTag, image: { - tag: 'latest', - pullPolicy: 'Always', + tag: imageTag, + pullPolicy: 'IfNotPresent', }, }, }, From c990bc7ab79a78e146ce5494955cc88ffe83e93e Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 22 Jul 2026 15:29:13 +0800 Subject: [PATCH 19/21] chore(ci): build amd64 images only --- .github/workflows/ci.yaml | 2 +- .github/workflows/pr.yaml | 4 ++-- .github/workflows/release.yaml | 2 +- Makefile | 5 +---- hack/test-delivery.sh | 8 +++++++- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8d772a4..6759947 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -99,7 +99,7 @@ jobs: with: context: . push: true - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 8a3a354..b2bd413 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -66,11 +66,11 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Build multi-platform image + - name: Build image uses: docker/build-push-action@v6 with: context: . push: false - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64 cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 77e9b1a..49250a1 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -65,7 +65,7 @@ jobs: with: context: . push: true - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha diff --git a/Makefile b/Makefile index 04ee15f..404dfaa 100644 --- a/Makefile +++ b/Makefile @@ -142,9 +142,6 @@ build: manifests generate fmt vet ## Build manager binary. run: manifests generate fmt vet ## Run a controller from your host. go run ./cmd/main.go -# If you wish to build the manager image targeting other platforms you can use the --platform flag. -# (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it. -# More info: https://docs.docker.com/develop/develop-images/build_enhancements/ .PHONY: docker-build docker-build: ## Build docker image with the manager. $(CONTAINER_TOOL) build -t ${IMG} . @@ -159,7 +156,7 @@ docker-push: ## Push docker image with the manager. # - have enabled BuildKit. More info: https://docs.docker.com/develop/develop-images/build_enhancements/ # - be able to push the image to your registry (i.e. if you do not set a valid value via IMG=> then the export will fail) # To adequately provide solutions that are compatible with multiple platforms, you should consider using this option. -PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le +PLATFORMS ?= linux/amd64 .PHONY: docker-buildx docker-buildx: ## Build and push docker image for the manager for cross-platform support # copy existing Dockerfile and insert --platform=${BUILDPLATFORM} into Dockerfile.cross, and preserve the original Dockerfile diff --git a/hack/test-delivery.sh b/hack/test-delivery.sh index f00db07..fb3ab13 100644 --- a/hack/test-delivery.sh +++ b/hack/test-delivery.sh @@ -5,7 +5,13 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "${repo_root}" -grep -Fq 'platforms: linux/amd64,linux/arm64' .github/workflows/pr.yaml +for workflow in .github/workflows/ci.yaml .github/workflows/pr.yaml .github/workflows/release.yaml; do + grep -Fq 'platforms: linux/amd64' "${workflow}" +done +if rg -q 'linux/arm64' .github/workflows Makefile; then + echo 'delivery must not build unused arm64 images' >&2 + exit 1 +fi if grep -Fq 'type=raw,value=latest' .github/workflows/release.yaml; then echo 'release workflow must not overwrite the main branch latest tag' >&2 exit 1 From 282c527fdd0aa17f2721636089a1ff0453642191 Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 22 Jul 2026 16:16:23 +0800 Subject: [PATCH 20/21] fix(powersync): gate readiness and external secrets --- api/v1alpha1/supabaseproject_types.go | 17 ++++- .../supabase.guion.dev_supabaseprojects.yaml | 19 ++++++ .../controller/powersync_lifecycle_test.go | 66 +++++++++++++++++++ .../controller/supabaseproject_controller.go | 15 +++-- internal/resources/secrets/secrets.go | 10 +-- 5 files changed, 115 insertions(+), 12 deletions(-) diff --git a/api/v1alpha1/supabaseproject_types.go b/api/v1alpha1/supabaseproject_types.go index 0e7cab7..fcc85e4 100644 --- a/api/v1alpha1/supabaseproject_types.go +++ b/api/v1alpha1/supabaseproject_types.go @@ -98,6 +98,7 @@ const ( ) // SupabaseProjectSpec defines the desired state of SupabaseProject +// +kubebuilder:validation:XValidation:rule="!has(self.powersync) || !has(self.secrets) || self.secrets.autoGenerate || (has(self.secrets.powersyncStoragePassword) && has(self.secrets.powersyncReplicationPassword))",message="PowerSync secret refs are required when PowerSync is enabled and autoGenerate is false" type SupabaseProjectSpec struct { // Database configuration for CNPG PostgreSQL cluster // +required @@ -306,6 +307,20 @@ type SecretsSpec struct { // Required when autoGenerate is false. // +optional AuthAdmin string `json:"authAdmin,omitempty"` + + // PowersyncStoragePassword references an existing secret containing 'username' and 'password' keys + // for the powersync_storage database role. + // Required when PowerSync is enabled and autoGenerate is false. + // +kubebuilder:validation:MinLength=1 + // +optional + PowersyncStoragePassword string `json:"powersyncStoragePassword,omitempty"` + + // PowersyncReplicationPassword references an existing secret containing 'username' and 'password' keys + // for the powersync_replication database role. + // Required when PowerSync is enabled and autoGenerate is false. + // +kubebuilder:validation:MinLength=1 + // +optional + PowersyncReplicationPassword string `json:"powersyncReplicationPassword,omitempty"` } // AuthSpec defines GoTrue auth service configuration @@ -712,7 +727,7 @@ type ServiceStatus struct { AvailableReplicas int32 `json:"availableReplicas,omitempty"` } -// SecretNamesStatus contains generated secret names +// SecretNamesStatus contains resolved secret names type SecretNamesStatus struct { // JWT is the name of the JWT secret // +optional diff --git a/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml b/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml index 9e1db23..418332d 100644 --- a/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml +++ b/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml @@ -1308,6 +1308,20 @@ spec: JWT references an existing JWT secret containing 'secret', 'anonKey', and 'serviceKey' keys. Required when autoGenerate is false. type: string + powersyncReplicationPassword: + description: |- + PowersyncReplicationPassword references an existing secret containing 'username' and 'password' keys + for the powersync_replication database role. + Required when PowerSync is enabled and autoGenerate is false. + minLength: 1 + type: string + powersyncStoragePassword: + description: |- + PowersyncStoragePassword references an existing secret containing 'username' and 'password' keys + for the powersync_storage database role. + Required when PowerSync is enabled and autoGenerate is false. + minLength: 1 + type: string supabaseAdmin: description: |- SupabaseAdmin references an existing secret containing 'username' and 'password' keys @@ -1409,6 +1423,11 @@ spec: - auth - database type: object + x-kubernetes-validations: + - message: PowerSync secret refs are required when PowerSync is enabled + and autoGenerate is false + rule: '!has(self.powersync) || !has(self.secrets) || self.secrets.autoGenerate + || (has(self.secrets.powersyncStoragePassword) && has(self.secrets.powersyncReplicationPassword))' status: description: SupabaseProjectStatus defines the observed state of SupabaseProject properties: diff --git a/internal/controller/powersync_lifecycle_test.go b/internal/controller/powersync_lifecycle_test.go index 4a634e2..a869d2b 100644 --- a/internal/controller/powersync_lifecycle_test.go +++ b/internal/controller/powersync_lifecycle_test.go @@ -2,6 +2,7 @@ package controller import ( "context" + "encoding/json" "testing" cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" @@ -140,7 +141,9 @@ func TestPowersyncDeploymentIsReadyForCurrentGeneration(t *testing.T) { Spec: appsv1.DeploymentSpec{Replicas: &replicas}, Status: appsv1.DeploymentStatus{ ObservedGeneration: 3, + UpdatedReplicas: 2, ReadyReplicas: 2, + AvailableReplicas: 2, }, } if !powersyncDeploymentIsReady(deployment) { @@ -151,10 +154,73 @@ func TestPowersyncDeploymentIsReadyForCurrentGeneration(t *testing.T) { t.Fatal("stale rollout status must not be ready") } deployment.Status.ObservedGeneration = 3 + deployment.Status.UpdatedReplicas = 1 + if powersyncDeploymentIsReady(deployment) { + t.Fatal("old ready pods must not make an incomplete rollout ready") + } + deployment.Status.UpdatedReplicas = 2 deployment.Status.ReadyReplicas = 1 if powersyncDeploymentIsReady(deployment) { t.Fatal("partial rollout must not be ready") } + deployment.Status.ReadyReplicas = 2 + deployment.Status.AvailableReplicas = 1 + deployment.Status.UnavailableReplicas = 1 + if powersyncDeploymentIsReady(deployment) { + t.Fatal("unavailable rollout must not be ready") + } +} + +func TestUserSpecifiedSecretsUsePowerSyncReferences(t *testing.T) { + t.Parallel() + + scheme := newPowerSyncTestScheme(t) + secretSpec := &supabasev1alpha1.SecretsSpec{} + if err := json.Unmarshal([]byte(`{ + "autoGenerate": false, + "jwt": "jwt", + "supabaseAdmin": "supabase-admin", + "authenticator": "authenticator", + "authAdmin": "auth-admin", + "powersyncStoragePassword": "powersync-storage", + "powersyncReplicationPassword": "powersync-replication" + }`), secretSpec); err != nil { + t.Fatal(err) + } + project := &supabasev1alpha1.SupabaseProject{ + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "default", UID: "project-uid"}, + Spec: supabasev1alpha1.SupabaseProjectSpec{ + Secrets: secretSpec, + Powersync: &supabasev1alpha1.PowersyncSpec{}, + }, + } + jwt := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "jwt", Namespace: "default"}, Data: map[string][]byte{ + "secret": {}, "anonKey": {}, "serviceKey": {}, + }} + objects := []client.Object{project, jwt} + for _, name := range []string{"supabase-admin", "authenticator", "auth-admin", "powersync-storage", "powersync-replication"} { + objects = append(objects, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, Data: map[string][]byte{ + "username": {}, "password": {}, + }}) + } + reconciler := &SupabaseProjectReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(project).WithObjects(objects...).Build(), + Scheme: scheme, + } + + if err := reconciler.reconcileUserSpecifiedSecrets(context.Background(), project); err != nil { + t.Fatal(err) + } + if project.Status.SecretNames.PowersyncStoragePassword != "powersync-storage" || + project.Status.SecretNames.PowersyncReplicationPassword != "powersync-replication" { + t.Fatalf("PowerSync secret refs not preserved in status: %#v", project.Status.SecretNames) + } + for _, name := range []string{"app-powersync-storage-password", "app-powersync-replication-password"} { + generated := &corev1.Secret{} + if err := reconciler.Get(context.Background(), client.ObjectKey{Namespace: "default", Name: name}, generated); !apierrors.IsNotFound(err) { + t.Fatalf("operator generated %s in user-specified mode: %v", name, err) + } + } } func TestPowerSyncManagedRolesReady(t *testing.T) { diff --git a/internal/controller/supabaseproject_controller.go b/internal/controller/supabaseproject_controller.go index d11867b..78ca3d7 100644 --- a/internal/controller/supabaseproject_controller.go +++ b/internal/controller/supabaseproject_controller.go @@ -324,6 +324,10 @@ func (r *SupabaseProjectReconciler) reconcileUserSpecifiedSecrets(ctx context.Co secretNames.Authenticator: "authenticator", secretNames.AuthAdmin: "supabase_auth_admin", } + if project.Spec.Powersync != nil { + roleSecrets[secretNames.PowersyncStoragePassword] = "powersync_storage" + roleSecrets[secretNames.PowersyncReplicationPassword] = "powersync_replication" + } for secretName, roleName := range roleSecrets { secret := &corev1.Secret{} @@ -347,12 +351,6 @@ func (r *SupabaseProjectReconciler) reconcileUserSpecifiedSecrets(ctx context.Co } } - if project.Spec.Powersync != nil { - if err := r.reconcilePowersyncSecrets(ctx, project, &secretNames); err != nil { - return err - } - } - // All secrets validated successfully project.Status.SecretNames = secretNames r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionTrue, "SecretsValidated", "All user-specified secrets are valid") @@ -1437,7 +1435,10 @@ func powersyncDeploymentIsReady(deployment *appsv1.Deployment) bool { expected = *deployment.Spec.Replicas } return deployment.Status.ObservedGeneration == deployment.Generation && - deployment.Status.ReadyReplicas == expected + deployment.Status.UpdatedReplicas == expected && + deployment.Status.ReadyReplicas == expected && + deployment.Status.AvailableReplicas == expected && + deployment.Status.UnavailableReplicas == 0 } // createOrUpdateCronJob creates or updates a CronJob resource diff --git a/internal/resources/secrets/secrets.go b/internal/resources/secrets/secrets.go index ad57e16..94c021b 100644 --- a/internal/resources/secrets/secrets.go +++ b/internal/resources/secrets/secrets.go @@ -166,10 +166,12 @@ func ValidateRoleSecret(secret *corev1.Secret, secretName string) error { // GetSecretNamesFromSpec extracts secret names from user-specified secrets configuration func GetSecretNamesFromSpec(spec *supabasev1alpha1.SecretsSpec) supabasev1alpha1.SecretNamesStatus { return supabasev1alpha1.SecretNamesStatus{ - JWT: spec.JWT, - SupabaseAdmin: spec.SupabaseAdmin, - Authenticator: spec.Authenticator, - AuthAdmin: spec.AuthAdmin, + JWT: spec.JWT, + SupabaseAdmin: spec.SupabaseAdmin, + Authenticator: spec.Authenticator, + AuthAdmin: spec.AuthAdmin, + PowersyncStoragePassword: spec.PowersyncStoragePassword, + PowersyncReplicationPassword: spec.PowersyncReplicationPassword, } } From 8a95354243c37c5a02328a6279db2035bfa7595f Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 22 Jul 2026 16:20:09 +0800 Subject: [PATCH 21/21] fix(ci): sync chart CRD before checks --- .../supabase.guion.dev_supabaseprojects.yaml | 19 +++++++++++++++++++ hack/test-delivery.sh | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml b/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml index 9e1db23..418332d 100644 --- a/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml +++ b/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml @@ -1308,6 +1308,20 @@ spec: JWT references an existing JWT secret containing 'secret', 'anonKey', and 'serviceKey' keys. Required when autoGenerate is false. type: string + powersyncReplicationPassword: + description: |- + PowersyncReplicationPassword references an existing secret containing 'username' and 'password' keys + for the powersync_replication database role. + Required when PowerSync is enabled and autoGenerate is false. + minLength: 1 + type: string + powersyncStoragePassword: + description: |- + PowersyncStoragePassword references an existing secret containing 'username' and 'password' keys + for the powersync_storage database role. + Required when PowerSync is enabled and autoGenerate is false. + minLength: 1 + type: string supabaseAdmin: description: |- SupabaseAdmin references an existing secret containing 'username' and 'password' keys @@ -1409,6 +1423,11 @@ spec: - auth - database type: object + x-kubernetes-validations: + - message: PowerSync secret refs are required when PowerSync is enabled + and autoGenerate is false + rule: '!has(self.powersync) || !has(self.secrets) || self.secrets.autoGenerate + || (has(self.secrets.powersyncStoragePassword) && has(self.secrets.powersyncReplicationPassword))' status: description: SupabaseProjectStatus defines the observed state of SupabaseProject properties: diff --git a/hack/test-delivery.sh b/hack/test-delivery.sh index fb3ab13..949ba11 100644 --- a/hack/test-delivery.sh +++ b/hack/test-delivery.sh @@ -8,7 +8,7 @@ cd "${repo_root}" for workflow in .github/workflows/ci.yaml .github/workflows/pr.yaml .github/workflows/release.yaml; do grep -Fq 'platforms: linux/amd64' "${workflow}" done -if rg -q 'linux/arm64' .github/workflows Makefile; then +if grep -R -q 'linux/arm64' .github/workflows Makefile; then echo 'delivery must not build unused arm64 images' >&2 exit 1 fi