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/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fefaf96..6759947 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 @@ -23,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: go test ./pkg/... -v + run: make test test-tanka test-delivery - name: Build run: go build -o bin/manager cmd/main.go @@ -32,22 +42,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 +62,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 +85,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 }} + 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..b2bd413 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -21,22 +21,22 @@ 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: go test ./pkg/... -v + run: make test test-tanka test-delivery - 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 @@ -53,5 +53,24 @@ 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] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + push: false + 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 8561f84..49250a1 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: @@ -25,8 +26,20 @@ 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: go test ./pkg/... -v + run: make test test-tanka test-delivery + + - 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 @@ -46,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 @@ -59,37 +71,19 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max + - 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}" + helm pull "${CHART_REGISTRY}/cloudnative-supabase" --version "${VERSION}" --destination /tmp + - 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/Makefile b/Makefile index f73308a..404dfaa 100644 --- a/Makefile +++ b/Makefile @@ -61,6 +61,36 @@ 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 + +.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: require-tanka-image ## Render the operator Tanka environment. + tk show $(TANKA_ENV) --ext-str imageTag=$(TANKA_IMAGE) + +.PHONY: tanka-diff +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: 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. # CertManager is installed by default; skip with: @@ -112,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} . @@ -129,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/README.md b/README.md index e6cd99e..8e4955f 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,47 @@ 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+ +- [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 -### 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 +# 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 "${VERSION}" ``` -### Deploy Operator +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 + +The self-contained environment in [`tanka/`](tanka/) renders this repository's +chart and CRD without Jsonnet dependencies: ```bash -kubectl apply -f https://raw.githubusercontent.com/GuionAI/cloudnative-supabase/main/dist/install.yaml +TANKA_IMAGE=sha-COMMIT make tanka-show +TANKA_IMAGE=sha-COMMIT make tanka-diff +TANKA_IMAGE=sha-COMMIT make tanka-apply ``` -Or using the Makefile: +This installs only the shared operator in `cnsupa-system`. Application-specific +`SupabaseProject` resources remain owned by their application repositories. + +### Install from source ```bash make deploy IMG=ghcr.io/guionai/cloudnative-supabase:latest @@ -168,6 +189,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 +252,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 +286,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 f16a32e..fcc85e4 100644 --- a/api/v1alpha1/supabaseproject_types.go +++ b/api/v1alpha1/supabaseproject_types.go @@ -89,9 +89,16 @@ const ( // ConditionTypeRecoveryReady indicates recovery infrastructure is ready ConditionTypeRecoveryReady = "RecoveryReady" + + // ConditionTypeCDCReady indicates CDC permissions have been applied + ConditionTypeCDCReady = "CDCReady" + + // ConditionTypePowersyncReady indicates Powersync is ready + ConditionTypePowersyncReady = "PowersyncReady" ) // 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 @@ -127,6 +134,10 @@ type SupabaseProjectSpec struct { // +optional Kong KongSpec `json:"kong,omitempty"` + // Powersync offline-first sync configuration (optional - presence enables Powersync) + // +optional + Powersync *PowersyncSpec `json:"powersync,omitempty"` + // ImagePullSecrets for all deployments // +optional ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"` @@ -173,7 +184,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"` @@ -296,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 @@ -521,6 +546,107 @@ 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., journeyapps/powersync-service) + // +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"` +} + +// PowersyncSpec defines Powersync offline-first sync configuration +type PowersyncSpec struct { + // Image configuration (default: journeyapps/powersync-service:1.20.4) + // +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 Streams configuration. Exactly one of inline or configMapRef is required. + // +required + SyncRules SyncRulesSpec `json:"syncRules"` + + // Compact CronJob configuration + // +optional + Compact PowersyncCompactSpec `json:"compact,omitempty"` +} + +// PowersyncAPISpec defines Powersync API deployment configuration +type PowersyncAPISpec struct { + // Replicas (default: 1) + // +kubebuilder:default=1 + // +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=150") + // +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=230") + // +optional + NodeOptions string `json:"nodeOptions,omitempty"` +} + +// 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 Streams YAML, including config.edition: 3. + // +kubebuilder:validation:MinLength=1 + // +optional + Inline string `json:"inline,omitempty"` + + // Reference to an external ConfigMap containing sync_rules.yaml. + // +kubebuilder:validation:MinLength=1 + // +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"` +} + // SupabaseProjectStatus defines the observed state of SupabaseProject type SupabaseProjectStatus struct { // Phase represents the current lifecycle phase @@ -585,6 +711,10 @@ type ServicesStatus struct { Meta ServiceStatus `json:"meta,omitempty"` // +optional Kong ServiceStatus `json:"kong,omitempty"` + // +optional + PowersyncAPI ServiceStatus `json:"powersyncApi,omitempty"` + // +optional + PowersyncReplication ServiceStatus `json:"powersyncReplication,omitempty"` } // ServiceStatus defines individual service status @@ -597,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 @@ -614,6 +744,14 @@ type SecretNamesStatus struct { // AuthAdmin is the name of the supabase_auth_admin password secret // +optional AuthAdmin string `json:"authAdmin,omitempty"` + + // PowersyncStoragePassword is the name of the powersync_storage role password secret + // +optional + PowersyncStoragePassword string `json:"powersyncStoragePassword,omitempty"` + + // PowersyncReplicationPassword is the name of the powersync_replication role password secret + // +optional + PowersyncReplicationPassword string `json:"powersyncReplicationPassword,omitempty"` } // EndpointsStatus contains service endpoints diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 72d2f4c..9d6126b 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -220,6 +220,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 @@ -294,6 +309,74 @@ 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 *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 @@ -414,6 +497,8 @@ func (in *ServicesStatus) DeepCopyInto(out *ServicesStatus) { out.Studio = in.Studio out.Meta = in.Meta out.Kong = in.Kong + out.PowersyncAPI = in.PowersyncAPI + out.PowersyncReplication = in.PowersyncReplication } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServicesStatus. @@ -520,6 +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.Powersync != nil { + in, out := &in.Powersync, &out.Powersync + *out = new(PowersyncSpec) + (*in).DeepCopyInto(*out) + } if in.ImagePullSecrets != nil { in, out := &in.ImagePullSecrets, &out.ImagePullSecrets *out = make([]v1.LocalObjectReference, len(*in)) @@ -562,3 +652,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/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml b/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml index acc3698..418332d 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: |- @@ -950,6 +950,257 @@ 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=150")' + type: string + replicas: + default: 1 + description: 'Replicas (default: 1)' + 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.20.4)' + properties: + pullPolicy: + default: IfNotPresent + description: 'PullPolicy (default: IfNotPresent)' + type: string + registry: + description: 'Registry (default: docker.io)' + type: string + repository: + description: Repository (e.g., journeyapps/powersync-service) + 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=230")' + 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 Streams configuration. Exactly one of inline + or configMapRef is required. + properties: + configMapRef: + description: Reference to an external ConfigMap containing + sync_rules.yaml. + minLength: 1 + type: string + inline: + 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) properties: @@ -1057,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 @@ -1158,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: @@ -1277,6 +1547,14 @@ spec: jwt: description: JWT is the name of the JWT 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 + type: string supabaseAdmin: description: SupabaseAdmin is the name of the supabase_admin password secret @@ -1327,6 +1605,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: 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: {} 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/templates/clusterrole.yaml b/charts/cloudnative-supabase/templates/clusterrole.yaml index 8cf0848..2eb2a4e 100644 --- a/charts/cloudnative-supabase/templates/clusterrole.yaml +++ b/charts/cloudnative-supabase/templates/clusterrole.yaml @@ -33,11 +33,26 @@ rules: - patch - update - watch - # CNPG Clusters and ScheduledBackups + # Jobs and CronJobs (PowerSync permissions and compaction) + - apiGroups: + - batch + resources: + - cronjobs + - jobs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + # 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..bcc8d9b 100644 --- a/charts/cloudnative-supabase/values.yaml +++ b/charts/cloudnative-supabase/values.yaml @@ -2,12 +2,15 @@ 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: [] 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/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml b/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml index acc3698..418332d 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: |- @@ -950,6 +950,257 @@ 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=150")' + type: string + replicas: + default: 1 + description: 'Replicas (default: 1)' + 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.20.4)' + properties: + pullPolicy: + default: IfNotPresent + description: 'PullPolicy (default: IfNotPresent)' + type: string + registry: + description: 'Registry (default: docker.io)' + type: string + repository: + description: Repository (e.g., journeyapps/powersync-service) + 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=230")' + 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 Streams configuration. Exactly one of inline + or configMapRef is required. + properties: + configMapRef: + description: Reference to an external ConfigMap containing + sync_rules.yaml. + minLength: 1 + type: string + inline: + 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) properties: @@ -1057,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 @@ -1158,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: @@ -1277,6 +1547,14 @@ spec: jwt: description: JWT is the name of the JWT 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 + type: string supabaseAdmin: description: SupabaseAdmin is the name of the supabase_admin password secret @@ -1327,6 +1605,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: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 963b4ec..86fd3d1 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -42,10 +42,24 @@ rules: - patch - update - watch +- apiGroups: + - batch + resources: + - cronjobs + - jobs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - 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 5656929..05690b7 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,17 @@ spec: projectName: Example Project # meta and kong use defaults (always enabled) + + # Optional PowerSync offline-first sync. Sync Streams are required and should + # select explicit columns guarded by auth.user_id(). + # 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() diff --git a/go.mod b/go.mod index 01bed09..3028ae0 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 @@ -12,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 ( @@ -22,9 +25,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 @@ -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..949ba11 --- /dev/null +++ b/hack/test-delivery.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +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 grep -R -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 +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 new file mode 100644 index 0000000..d30f4c6 --- /dev/null +++ b/hack/test-tanka.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +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}" + +assert_resource_contains() { + local target="$1" + local expected="$2" + + tk show tanka/environments/guion \ + --dangerous-allow-redirect \ + --ext-str "imageTag=${image_tag}" \ + --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:${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 new file mode 100644 index 0000000..daea9e0 --- /dev/null +++ b/internal/controller/cnpg_roles_test.go @@ -0,0 +1,51 @@ +package controller + +import ( + "testing" + + cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/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{ + 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..a869d2b --- /dev/null +++ b/internal/controller/powersync_lifecycle_test.go @@ -0,0 +1,464 @@ +package controller + +import ( + "context" + "encoding/json" + "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, + UpdatedReplicas: 2, + ReadyReplicas: 2, + AvailableReplicas: 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.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) { + 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/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 39bcba2..78ca3d7 100644 --- a/internal/controller/supabaseproject_controller.go +++ b/internal/controller/supabaseproject_controller.go @@ -18,13 +18,17 @@ package controller import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "time" 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" + 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" @@ -33,12 +37,16 @@ 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" "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" ) @@ -87,11 +95,14 @@ 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 // +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. @@ -146,6 +157,28 @@ func (r *SupabaseProjectReconciler) Reconcile(ctx context.Context, req ctrl.Requ return ctrl.Result{}, err } + // 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 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 project.Status.Phase = supabasev1alpha1.PhaseRunning project.Status.ObservedGeneration = project.Generation @@ -159,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) @@ -209,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{} @@ -281,6 +400,14 @@ func (r *SupabaseProjectReconciler) reconcileAutoGeneratedSecrets(ctx context.Co if allExist { log.Info("Secrets already exist in cluster, syncing status") + + // Also sync optional service secrets + 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") if err := r.Status().Update(ctx, project); err != nil { @@ -314,6 +441,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 @@ -325,6 +459,56 @@ func (r *SupabaseProjectReconciler) reconcileAutoGeneratedSecrets(ctx context.Co 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) + + allExist := true + 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) { + allExist = false + break + } + return err + } + } + if allExist { + log.Info("Powersync secrets already exist, syncing status") + secretNames.PowersyncStoragePassword = storagePwdName + secretNames.PowersyncReplicationPassword = replPwdName + return nil + } + + // 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 + secretNames.PowersyncReplicationPassword = replPwdName + return nil +} + // reconcileInitSQL ensures the init SQL ConfigMap exists func (r *SupabaseProjectReconciler) reconcileInitSQL(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { log := logf.FromContext(ctx) @@ -370,9 +554,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) @@ -938,10 +1140,401 @@ func (r *SupabaseProjectReconciler) createOrUpdateScheduledBackup(ctx context.Co return nil } +// reconcileCDCPermissions ensures CDC permissions are applied via a Job. +// Returns RequeueAfter when the Job is still running so we don't proceed +// 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") + + 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 ctrl.Result{}, statusErr + } + 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) + 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 ctrl.Result{}, statusErr + } + return ctrl.Result{}, err + } + + 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 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[:]) +} + +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 + } + if err := controllerutil.SetControllerReference(project, desired, r.Scheme); err != nil { + return ctrl.Result{}, err + } + if err := r.Create(ctx, desired); err != nil { + return ctrl.Result{}, err + } + 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 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 != "" { + message = existing.Status.Message + } + 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 ctrl.Result{}, nil +} + +func publicationIsApplied(publication *cnpgv1.Publication) bool { + 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, syncRulesContent []byte) (ctrl.Result, error) { + log := logf.FromContext(ctx) + log.Info("Reconciling Powersync service") + + secretNames := &project.Status.SecretNames + + // Create Powersync config ConfigMap + 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 { + return ctrl.Result{}, statusErr + } + return ctrl.Result{}, err + } + + // Create sync rules ConfigMap (may be nil if external ConfigMapRef is used) + 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 ctrl.Result{}, statusErr + } + 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 ctrl.Result{}, statusErr + } + return ctrl.Result{}, 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 ctrl.Result{}, statusErr + } + 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 ctrl.Result{}, statusErr + } + return ctrl.Result{}, 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 ctrl.Result{}, statusErr + } + 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 + } + + r.setCondition(project, supabasev1alpha1.ConditionTypePowersyncReady, metav1.ConditionTrue, "Ready", "Powersync service is running") + 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.UpdatedReplicas == expected && + deployment.Status.ReadyReplicas == expected && + deployment.Status.AvailableReplicas == expected && + deployment.Status.UnavailableReplicas == 0 +} + +// 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) +} + +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. 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) { + log := logf.FromContext(ctx) + + // Set owner reference + if err := controllerutil.SetControllerReference(project, job, r.Scheme); err != nil { + 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 false, r.Create(ctx, job) + } + 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 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 + log.V(1).Info("Job still running", "name", job.Name, "active", existing.Status.Active) + return false, nil +} + func (r *SupabaseProjectReconciler) setCondition(project *supabasev1alpha1.SupabaseProject, conditionType string, status metav1.ConditionStatus, reason, message string) { condition := metav1.Condition{ Type: conditionType, Status: status, + ObservedGeneration: project.Generation, LastTransitionTime: metav1.Now(), Reason: reason, Message: message, @@ -949,15 +1542,36 @@ 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{}). + 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 897ccf6..60a1380 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,44 @@ func buildBootstrapConfiguration(project *supabasev1alpha1.SupabaseProject, secr } } +// 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.Powersync != nil && secretNames.PowersyncStoragePassword != "" && secretNames.PowersyncReplicationPassword != "" { + roles = append(roles, BuildPowersyncRoles(secretNames)...) + } + return roles +} + +// 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{ + { + Name: "powersync_storage", + Ensure: cnpgv1.EnsurePresent, + Login: true, + PasswordSecret: &cnpgv1.LocalObjectReference{ + Name: secretNames.PowersyncStoragePassword, + }, + 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", + }, + } +} + // 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/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 new file mode 100644 index 0000000..d0300cb --- /dev/null +++ b/internal/resources/configmaps/powersync.go @@ -0,0 +1,177 @@ +/* +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" + + 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"` + 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 { + 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 powersyncDev struct { + DemoAuth bool `json:"demo_auth"` +} + +type powersyncMigrations struct { + DisableAutoMigration bool `json:"disable_auto_migration"` +} + +type powersyncSyncRules struct { + 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 variable templates that +// PowerSync resolves at runtime. +func BuildPowersyncConfigMap(project *supabasev1alpha1.SupabaseProject) *corev1.ConfigMap { + config := powersyncConfig{ + Storage: powersyncStorage{ + Type: "postgresql", + URI: "{{ env.PS_POWERSYNC_STORAGE_URI }}", + }, + Replication: powersyncReplication{ + Connections: []powersyncConnection{ + { + Type: "postgresql", + 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", + ExitOnError: true, + }, + Telemetry: powersyncTelemetry{DisableTelemetrySharing: false}, + } + + 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 + } + + // 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 == "" { + return nil + } + + 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) +} diff --git a/internal/resources/configmaps/powersync_test.go b/internal/resources/configmaps/powersync_test.go new file mode 100644 index 0000000..5751f11 --- /dev/null +++ b/internal/resources/configmaps/powersync_test.go @@ -0,0 +1,197 @@ +package configmaps + +import ( + "encoding/json" + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + 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, + }, + Spec: supabasev1alpha1.SupabaseProjectSpec{ + 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()", + }, + }, + }, + } +} + +func TestPowersyncConfigMapName(t *testing.T) { + project := newTestProject("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("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("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(testNamespace) + cm := BuildPowersyncConfigMap(project) + + if cm.Name != "my-app-powersync-config" { + t.Errorf("Name = %q, want %q", cm.Name, "my-app-powersync-config") + } + if cm.Namespace != testNamespace { + t.Errorf("Namespace = %q, want %q", cm.Namespace, testNamespace) + } + + 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 config.Storage.URI != "{{ env.PS_POWERSYNC_STORAGE_URI }}" { + t.Errorf("storage URI = %q, want environment template", config.Storage.URI) + } + + // 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) + } + if conn.URI != "{{ env.PS_POWERSYNC_REPLICATION_URI }}" { + t.Errorf("replication URI = %q, want environment template", conn.URI) + } + + // 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) + } + if !config.SyncRules.ExitOnError { + t.Error("sync rules must fail startup when invalid") + } +} + +func TestBuildPowersyncSyncRulesConfigMap_UsesSyncStreams(t *testing.T) { + 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") + } + + syncRules, ok := cm.Data["sync_rules.yaml"] + if !ok { + t.Fatal("sync_rules.yaml key not found") + } + 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 = "config:\n edition: 3\nstreams:\n custom:\n query: SELECT id FROM users WHERE id = auth.user_id()" + + 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") + } +} + +func TestBuildPowersyncSyncRulesConfigMap_ExternalRef(t *testing.T) { + project := newTestProject("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/defaults/images.go b/internal/resources/defaults/images.go index 952efbe..f8890a7 100644 --- a/internal/resources/defaults/images.go +++ b/internal/resources/defaults/images.go @@ -25,4 +25,8 @@ const ( // Kong image defaults KongImage = "kong" KongTag = "2.8.1" + + // Powersync image defaults + PowersyncImage = "journeyapps/powersync-service" + PowersyncTag = "1.20.4" ) diff --git a/internal/resources/deployments/helpers.go b/internal/resources/deployments/helpers.go index 5941c58..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 { @@ -123,7 +150,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()) + } +} diff --git a/internal/resources/deployments/powersync.go b/internal/resources/deployments/powersync.go new file mode 100644 index 0000000..698c1c0 --- /dev/null +++ b/internal/resources/deployments/powersync.go @@ -0,0 +1,452 @@ +/* +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("180Mi"), + corev1.ResourceCPU: resource.MustParse("100m"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("360Mi"), + corev1.ResourceCPU: resource.MustParse("1"), + }, + } +} + +// 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("512Mi"), + corev1.ResourceCPU: resource.MustParse("1"), + }, + } +} + +// 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 + 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=150" + } + + 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, + Args: []string{"start", "-r", "api"}, + Env: env, + Ports: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: PowersyncHTTPPort, + Protocol: corev1.ProtocolTCP, + }, + { + Name: "metrics", + ContainerPort: PowersyncMetricsPort, + Protocol: corev1.ProtocolTCP, + }, + }, + LivenessProbe: powersyncLivenessProbe(), + 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), + }, + }, + }, + } + + 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=230" + } + + 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, + Args: []string{"start", "-r", "sync"}, + Env: env, + Ports: []corev1.ContainerPort{ + { + Name: "metrics", + ContainerPort: PowersyncMetricsPort, + Protocol: corev1.ProtocolTCP, + }, + }, + LivenessProbe: powersyncLivenessProbe(), + 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), + }, + }, + }, + } + + 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, DefaultPowersyncCompactResources()) + + schedule := spec.Compact.Schedule + if schedule == "" { + schedule = "0 3 * * *" + } + + env := buildPowersyncEnv(project, secretNames, "--max-old-space-size=512") + + cronJob := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: project.Namespace, + Labels: common.ComponentLabels(project, PowersyncCompactComponentName), + }, + Spec: batchv1.CronJobSpec{ + 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.RestartPolicyNever, + Containers: []corev1.Container{ + { + Name: "powersync-compact", + Image: image, + ImagePullPolicy: pullPolicy, + Args: []string{"compact"}, + Env: env, + Resources: resources, + VolumeMounts: powersyncVolumeMounts(), + }, + }, + Volumes: powersyncVolumes(project), + }, + }, + }, + }, + }, + } + 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 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{ + Exec: &corev1.ExecAction{Command: []string{"sh", "-c", "sleep 5"}}, + }, + } +} + +// 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}, + {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", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secretNames.PowersyncStoragePassword, + }, + Key: "password", + }, + }, + }, + // 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_STORAGE_PASSWORD)@%s:5432/supabase?sslmode=disable", dbHost), + }, + { + Name: "PS_POWERSYNC_REPLICATION_URI", + Value: fmt.Sprintf("postgresql://powersync_replication:$(PS_REPLICATION_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, fallback corev1.ResourceRequirements) corev1.ResourceRequirements { + if len(resources.Requests) == 0 && len(resources.Limits) == 0 { + return 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 new file mode 100644 index 0000000..dbfb9b1 --- /dev/null +++ b/internal/resources/deployments/powersync_test.go @@ -0,0 +1,298 @@ +package deployments + +import ( + "slices" + "testing" + + 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") + + 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(testNamespace) + 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 != testNamespace { + t.Errorf("Namespace = %q, want %q", dep.Namespace, testNamespace) + } + + // 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) + } + + // 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 + 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) + } + + // PowerSync 1.20 filesystem probes. + 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") + } + if c.StartupProbe == nil || c.StartupProbe.Exec == nil || c.StartupProbe.Exec.Command[1] != "/app/.probes/startup" { + t.Error("expected filesystem startup probe") + } + + // 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("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) { + const ( + nodeOptionsName = "NODE_OPTIONS" + nodeOptionsValue = "--max-old-space-size=512" + ) + project := newTestProject("default") + 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 == nodeOptionsName { + if e.Value != nodeOptionsValue { + t.Errorf("NODE_OPTIONS = %q, want %s", e.Value, nodeOptionsValue) + } + return + } + } + t.Error("NODE_OPTIONS env var not found") +} + +func TestBuildPowersyncReplicationDeployment(t *testing.T) { + project := newTestProject(testNamespace) + 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] + + 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) + 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 { + if e.Name == "NODE_OPTIONS" { + if e.Value != "--max-old-space-size=230" { + t.Errorf("NODE_OPTIONS = %q, want --max-old-space-size=230", e.Value) + } + return + } + } + 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() + + 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 != testNamespace { + t.Errorf("Namespace = %q, want %q", cj.Namespace, testNamespace) + } + + // Default schedule + if cj.Spec.Schedule != "0 3 * * *" { + t.Errorf("Schedule = %q, want %q", cj.Spec.Schedule, "0 3 * * *") + } + + // Compact uses the image entrypoint. + c := cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0] + 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) + } + + 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) { + project := newTestProject("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("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("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", "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/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 new file mode 100644 index 0000000..90f9d44 --- /dev/null +++ b/internal/resources/jobs/cdc_permissions.go @@ -0,0 +1,202 @@ +/* +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 { + 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 PowerSync database setup script. +func buildCDCSetupScript(project *supabasev1alpha1.SupabaseProject) string { + script := `#!/bin/sh +set -e + +echo "=== CDC Permissions Setup ===" +` + + // 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 (internal sync state) +GRANT CREATE ON DATABASE supabase 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 + +` + } + + script += ` +echo "=== CDC Permissions Setup Complete ===" +` + return script +} + +// 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/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 3d06ecb..94c021b 100644 --- a/internal/resources/secrets/secrets.go +++ b/internal/resources/secrets/secrets.go @@ -166,9 +166,38 @@ 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, } } + +// GeneratePowersyncSecrets generates Powersync-related secrets +func GeneratePowersyncSecrets(project *supabasev1alpha1.SupabaseProject) ([]*corev1.Secret, error) { + var secrets []*corev1.Secret + + // 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, powersyncReplicationPassword string) { + return project.Name + "-powersync-storage-password", + project.Name + "-powersync-replication-password" +} diff --git a/internal/resources/secrets/secrets_test.go b/internal/resources/secrets/secrets_test.go new file mode 100644 index 0000000..79558b6 --- /dev/null +++ b/internal/resources/secrets/secrets_test.go @@ -0,0 +1,47 @@ +package secrets + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" +) + +func newTestProject(namespace string) *supabasev1alpha1.SupabaseProject { + return &supabasev1alpha1.SupabaseProject{ + ObjectMeta: metav1.ObjectMeta{Name: "my-app", Namespace: namespace}, + Spec: supabasev1alpha1.SupabaseProjectSpec{ + Powersync: &supabasev1alpha1.PowersyncSpec{}, + }, + } +} + +func TestPowersyncSecretNames(t *testing.T) { + storagePwd, replPwd := PowersyncSecretNames(newTestProject("default")) + if storagePwd != "my-app-powersync-storage-password" { + t.Errorf("storagePwd = %q", storagePwd) + } + if replPwd != "my-app-powersync-replication-password" { + t.Errorf("replPwd = %q", replPwd) + } +} + +func TestGeneratePowersyncSecrets(t *testing.T) { + generated, err := GeneratePowersyncSecrets(newTestProject("test-ns")) + if err != nil { + t.Fatalf("GeneratePowersyncSecrets() error = %v", err) + } + if len(generated) != 2 { + t.Fatalf("expected 2 secrets, got %d", len(generated)) + } + if generated[0].StringData["username"] != "powersync_storage" { + t.Errorf("storage username = %q", generated[0].StringData["username"]) + } + if generated[1].StringData["username"] != "powersync_replication" { + t.Errorf("replication username = %q", generated[1].StringData["username"]) + } + 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 f6ecddb..d06fde7 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) } +// 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{ diff --git a/internal/resources/services/services_test.go b/internal/resources/services/services_test.go new file mode 100644 index 0000000..48737bf --- /dev/null +++ b/internal/resources/services/services_test.go @@ -0,0 +1,30 @@ +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 TestBuildPowersyncAPIService(t *testing.T) { + project := &supabasev1alpha1.SupabaseProject{ + ObjectMeta: metav1.ObjectMeta{Name: "my-app", Namespace: "test-ns"}, + } + svc := BuildPowersyncAPIService(project) + + 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) + } + 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) + } + if svc.Spec.Selector["app.kubernetes.io/component"] != "powersync-api" { + t.Errorf("unexpected selector: %v", svc.Spec.Selector) + } +} diff --git a/tanka/README.md b/tanka/README.md new file mode 100644 index 0000000..d397b1d --- /dev/null +++ b/tanka/README.md @@ -0,0 +1,20 @@ +# 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. Supply an +immutable release or `sha-...` image tag so each change produces a real rollout +and can be reproduced or rolled back. + +```sh +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. +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..87a4bd6 --- /dev/null +++ b/tanka/environments/guion/main.jsonnet @@ -0,0 +1,31 @@ +local namespace = 'cnsupa-system'; +local imageTag = std.extVar('imageTag'); +local chart = std.native('helmTemplate')( + 'cloudnative-supabase', + '../../../charts/cloudnative-supabase', + { + calledFrom: std.thisFile, + namespace: namespace, + values: { + versionOverride: imageTag, + image: { + tag: imageTag, + pullPolicy: 'IfNotPresent', + }, + }, + }, +); + +{ + 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 +}