diff --git a/CLAUDE.md b/CLAUDE.md index 5c8694d..c093973 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ charts/cloudnative-supabase/crds # chart copy of generated CRD durable resources before validating external input. 2. Validate the externally managed `projectCredentialsSecret` bundle. 3. Create-once implementation Secrets (database role passwords, GoTrue - fallback, optional email hook and PowerSync credentials). + fallback, optional email hook, and PowerSync database/API credentials). 4. Validate immutable recovery intent, then create init SQL and public JWKS ConfigMaps. 5. Reconcile independent recovery and steady-state backup resources. @@ -37,7 +37,9 @@ charts/cloudnative-supabase/crds # chart copy of generated CRD fields outside that projection, rejecting bootstrap mutation and storage shrink. 7. Wait for ready database instances. -8. Reconcile Auth, REST, Studio, Meta, Envoy, then optional PowerSync. +8. Reconcile Auth, REST, Studio, Meta, Envoy, then optional PowerSync. The + PowerSync config hash covers the generated native metrics and diagnostics + configuration, while token values remain outside hashes. When backup and recovery are both enabled, their deterministic ObjectStore names and configured destination paths must differ. A credentials Secret may be @@ -69,7 +71,23 @@ secret and normalized Auth issuer. Envoy receives opaque keys and internal role tokens. Studio receives opaque keys and the internal role-token variables it supports. PostgREST receives only public JWKS. PowerSync uses the Auth JWKS URL, audience `authenticated`, and disabled Supabase HMAC mode; it has no JWT -secret environment variable. +secret environment variable. The default PowerSync image is +`journeyapps/powersync-service:1.21.0`; its generated config enables native +Prometheus metrics on named port 9464 and loads administrative API tokens from +`PS_POWERSYNC_API_TOKEN`. + +The operator creates the PowerSync API token only when PowerSync is enabled, in +the same-namespace create-once Secret `-powersync-api-token` under key +`token`. It is runtime-owned, independent of the five external project +credentials, and never appears in ConfigMaps, status, annotations, or logs. +Existing non-empty values are retained; invalid or foreign-controlled +same-name Secrets fail safely without adoption. Explicit token rotation is an +operator Secret operation and requires restarting the API/replication processes +that consume it; a monitoring workload can use its mounted Secret without +Secret-value API permission. PowerSync replication uses a Recreate Deployment +strategy; the client-facing API retains its rolling/default strategy and +existing availability probes. The configured image override feeds API, +replication, and compaction. `publishableKey` and `secretKey` use Supabase's canonical self-hosted opaque-key format: their role-specific prefix is followed by exactly 22 unpadded diff --git a/README.md b/README.md index b83927a..c69eb24 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ The Envoy assets are adapted from the official self-hosted Supabase assets at upstream commit [`95ca3024398080ff18c9abcd1c6c8beae73fd9e1`](https://github.com/supabase/supabase/commit/95ca3024398080ff18c9abcd1c6c8beae73fd9e1). Pinned images are Envoy `envoyproxy/envoy:v1.39.0`, GoTrue -`supabase/gotrue:v2.189.0`, and PostgREST `postgrest/postgrest:v14.12`. +`supabase/gotrue:v2.189.0`, PostgREST `postgrest/postgrest:v14.12`, and +PowerSync `journeyapps/powersync-service:1.21.0` when PowerSync is enabled. ## Project credentials @@ -162,6 +163,58 @@ operator defaults. `auth.goTrueEnv` remains available for provider settings, but JWT keys, fallback secret, key ID, issuer, audience, lifetime, valid methods, and role settings are operator-owned and cannot be overridden. +## Optional PowerSync profile + +Adding `spec.powersync` enables the managed PowerSync 1.21.0 profile. The +default image is used by the API Deployment, the singleton replication +Deployment, and the optional compaction CronJob. An explicit +`spec.powersync.image` override remains available and is applied consistently +to all three managed PowerSync commands. + +The API and replication containers expose a named `metrics` port on TCP 9464, +and the generated PowerSync configuration sets +`telemetry.prometheus_port: 9464`. The API Service exposes both HTTP 8080 and +metrics 9464; the replication pod exposes its named metrics port for a pod +scrape. Both roles serve `GET /metrics`. Kubernetes startup, readiness, and +liveness probes use filesystem `Exec` actions; the API's existing HTTP health +route remains available to external monitors. Replication uses a Recreate +rollout so two replication processes do not overlap during replacement. + +The operator creates one independent, create-once Secret in the project +namespace: `-powersync-api-token`, with the required key `token`. +PowerSync loads that value through a `secretKeyRef`; it is not part of the +five-field project credential bundle and is never copied into a ConfigMap or +project status. Monitoring in the same namespace can mount this Secret and +use the token as `Authorization: Bearer ` for the authenticated +`POST /api/admin/v1/diagnostics` endpoint. A pre-existing non-empty token is +preserved. A missing or invalid key blocks the PowerSync secret phase without +printing the value, and a same-name Secret controlled by another resource is +not adopted. + +To rotate the internal token, replace only the `token` key using the approved +Secret management path. The operator does not rotate it during reconciliation; +restart or roll out the API and replication processes (and any compaction +process that is running) so they reload the value. A monitor only needs access +to its mounted Secret and does not need Secret-value API permission. Disabling +PowerSync prevents new PowerSync implementation Secrets from being generated; +existing create-once implementation credentials follow the operator's normal +retention behavior. + +For an upgrade, validate the development project first. Confirm the generated +images and named ports, scrape `/metrics` for both roles, authenticate a +Diagnostics request, and run an existing-client smoke check. Also confirm that +the database role Secret bytes, edition-3 sync rules, project credential +identity, and application data remain unchanged. This is an operator +verification procedure; the repository does not claim a live deployment. + +The 1.20.4-to-1.21.0 change has no new operational PostgreSQL storage +migration script, but that is not a blanket rollback guarantee. Rolling an +image back does not reverse persistent PowerSync state or database changes. +Keep an appropriate backup and use the recovery/runbook decision for the +observed failure instead of assuming an image rollback restores the prior +state. No sync-rule conversion, dual-running path, or automatic data recovery +is provided by this feature. + ## CNPG configuration source of truth `SupabaseProject` is the single supported customization interface for the diff --git a/api/v1alpha1/supabaseproject_types.go b/api/v1alpha1/supabaseproject_types.go index 36ccd7a..5000169 100644 --- a/api/v1alpha1/supabaseproject_types.go +++ b/api/v1alpha1/supabaseproject_types.go @@ -539,7 +539,7 @@ type ImageSpec struct { // PowersyncSpec defines Powersync offline-first sync configuration type PowersyncSpec struct { - // Image configuration (default: journeyapps/powersync-service:1.20.4) + // Image configuration (default: journeyapps/powersync-service:1.21.0) // +optional Image ImageSpec `json:"image,omitempty"` diff --git a/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml b/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml index b0f9064..39cb544 100644 --- a/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml +++ b/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml @@ -1150,7 +1150,7 @@ spec: type: string type: object image: - description: 'Image configuration (default: journeyapps/powersync-service:1.20.4)' + description: 'Image configuration (default: journeyapps/powersync-service:1.21.0)' properties: pullPolicy: default: IfNotPresent diff --git a/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml b/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml index b0f9064..39cb544 100644 --- a/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml +++ b/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml @@ -1150,7 +1150,7 @@ spec: type: string type: object image: - description: 'Image configuration (default: journeyapps/powersync-service:1.20.4)' + description: 'Image configuration (default: journeyapps/powersync-service:1.21.0)' properties: pullPolicy: default: IfNotPresent diff --git a/config/samples/supabase_v1alpha1_supabaseproject.yaml b/config/samples/supabase_v1alpha1_supabaseproject.yaml index c5238d1..ec21784 100644 --- a/config/samples/supabase_v1alpha1_supabaseproject.yaml +++ b/config/samples/supabase_v1alpha1_supabaseproject.yaml @@ -40,8 +40,12 @@ spec: # Meta and Envoy use defaults (all core services are always enabled). - # Optional PowerSync offline-first sync. Sync Streams are required and should - # select explicit columns guarded by auth.user_id(). + # Optional PowerSync offline-first sync. The managed default is + # journeyapps/powersync-service:1.21.0 with native metrics on named port + # 9464 and an operator-generated same-namespace -powersync-api-token + # Secret (key: token) for authenticated Diagnostics. Sync Streams are required + # and should select explicit columns guarded by auth.user_id(). An explicit + # image override applies to API, replication, and compaction. # powersync: # api: # replicas: 1 diff --git a/internal/controller/powersync_observability_test.go b/internal/controller/powersync_observability_test.go new file mode 100644 index 0000000..83cb026 --- /dev/null +++ b/internal/controller/powersync_observability_test.go @@ -0,0 +1,295 @@ +package controller + +import ( + "context" + "strings" + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + "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" + deploymentresources "github.com/GuionAI/cloudnative-supabase/internal/resources/deployments" + secretresources "github.com/GuionAI/cloudnative-supabase/internal/resources/secrets" +) + +func TestReconcilePowersyncSecretsCreatesAPITokenWithoutRotatingExistingRoles(t *testing.T) { + t.Parallel() + + project := powersyncTokenTestProject("token-create") + scheme := newPowerSyncTestScheme(t) + storage := powersyncRoleSecret(project, "powersync-storage-password", "powersync_storage", "storage-before") + replication := powersyncRoleSecret(project, "powersync-replication-password", "powersync_replication", "replication-before") + reconciler := &SupabaseProjectReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(project, storage, replication).Build(), + Scheme: scheme, + } + names := &supabasev1alpha1.SecretNamesStatus{} + + if err := reconciler.reconcilePowersyncSecrets(context.Background(), project, names); err != nil { + t.Fatalf("reconcilePowersyncSecrets() error = %v", err) + } + + assertPowerSyncRolePassword(t, reconciler, storage, "storage-before") + assertPowerSyncRolePassword(t, reconciler, replication, "replication-before") + token := &corev1.Secret{} + if err := reconciler.Get(context.Background(), types.NamespacedName{ + Name: secretresources.PowersyncAPITokenSecretName(project), Namespace: project.Namespace, + }, token); err != nil { + t.Fatalf("created API token Secret: %v", err) + } + if err := secretresources.ValidatePowersyncAPITokenSecret(token); err != nil { + t.Fatalf("created API token Secret failed validation: %v", err) + } + if owner := metav1.GetControllerOf(token); owner == nil || owner.Name != project.Name || owner.UID != project.UID { + t.Fatalf("API token Secret owner = %#v, want project %s/%s", owner, project.Namespace, project.Name) + } + if names.PowersyncStoragePassword != storage.Name || names.PowersyncReplicationPassword != replication.Name { + t.Fatalf("PowerSync role names = %#v", names) + } +} + +func TestReconcileImplementationSecretsCreatesPowerSyncTokenForFreshEnabledProject(t *testing.T) { + t.Parallel() + + project := powersyncTokenTestProject("token-fresh") + scheme := newPowerSyncTestScheme(t) + reconciler := &SupabaseProjectReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(project).Build(), + Scheme: scheme, + } + if err := reconciler.reconcileImplementationSecrets(context.Background(), project); err != nil { + t.Fatalf("reconcileImplementationSecrets() error = %v", err) + } + + for _, name := range []string{ + secretresources.PowersyncAPITokenSecretName(project), + project.Name + "-powersync-storage-password", + project.Name + "-powersync-replication-password", + } { + secret := &corev1.Secret{} + if err := reconciler.Get(context.Background(), types.NamespacedName{Name: name, Namespace: project.Namespace}, secret); err != nil { + t.Fatalf("fresh PowerSync Secret %q: %v", name, err) + } + } +} + +func TestReconcilePowersyncSecretsPreservesTokenAndRoleCredentialsOnRepeat(t *testing.T) { + t.Parallel() + + project := powersyncTokenTestProject("token-repeat") + scheme := newPowerSyncTestScheme(t) + storage := powersyncRoleSecret(project, "powersync-storage-password", "powersync_storage", "storage-stable") + replication := powersyncRoleSecret(project, "powersync-replication-password", "powersync_replication", "replication-stable") + token := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secretresources.PowersyncAPITokenSecretName(project), Namespace: project.Namespace}, + Data: map[string][]byte{secretresources.PowersyncAPITokenSecretKey: []byte("fixture-api-token")}, + } + reconciler := &SupabaseProjectReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(project, storage, replication, token).Build(), + Scheme: scheme, + } + + for range 2 { + if err := reconciler.reconcilePowersyncSecrets(context.Background(), project, &supabasev1alpha1.SecretNamesStatus{}); err != nil { + t.Fatalf("reconcilePowersyncSecrets() error = %v", err) + } + } + + assertPowerSyncRolePassword(t, reconciler, storage, "storage-stable") + assertPowerSyncRolePassword(t, reconciler, replication, "replication-stable") + updatedToken := &corev1.Secret{} + if err := reconciler.Get(context.Background(), client.ObjectKeyFromObject(token), updatedToken); err != nil { + t.Fatal(err) + } + if got := string(updatedToken.Data[secretresources.PowersyncAPITokenSecretKey]); got != "fixture-api-token" { + t.Fatalf("API token changed on repeat reconcile: %q", got) + } + if owner := metav1.GetControllerOf(updatedToken); owner == nil || owner.Name != project.Name { + t.Fatalf("existing API token Secret was not adopted by the project: %#v", owner) + } +} + +func TestReconcilePowersyncSecretsRejectsInvalidAPITokenWithoutReplacement(t *testing.T) { + t.Parallel() + + project := powersyncTokenTestProject("token-invalid") + scheme := newPowerSyncTestScheme(t) + storage := powersyncRoleSecret(project, "powersync-storage-password", "powersync_storage", "storage-stable") + replication := powersyncRoleSecret(project, "powersync-replication-password", "powersync_replication", "replication-stable") + token := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secretresources.PowersyncAPITokenSecretName(project), Namespace: project.Namespace}, + Data: map[string][]byte{}, + } + reconciler := &SupabaseProjectReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(project, storage, replication, token).Build(), + Scheme: scheme, + } + + err := reconciler.reconcilePowersyncSecrets(context.Background(), project, &supabasev1alpha1.SecretNamesStatus{}) + if err == nil || !strings.Contains(err.Error(), "missing required non-empty key") { + t.Fatalf("invalid API token result = %v, want missing-key error", err) + } + if strings.Contains(err.Error(), "fixture") { + t.Fatalf("API token value leaked in validation error: %v", err) + } + unchanged := &corev1.Secret{} + if err := reconciler.Get(context.Background(), client.ObjectKeyFromObject(token), unchanged); err != nil { + t.Fatal(err) + } + if len(unchanged.Data) != 0 { + t.Fatalf("invalid API token Secret was replaced: %#v", unchanged.Data) + } +} + +func TestReconcileSecretsReportsPowerSyncTokenFailureWithoutValue(t *testing.T) { + t.Parallel() + + project := powersyncTokenTestProject("token-status") + project.Spec.ProjectCredentialsSecret = "token-status-credentials" + scheme := newPowerSyncTestScheme(t) + credentials := validProjectCredentialsSecret(t, project, project.Spec.ProjectCredentialsSecret) + invalidToken := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secretresources.PowersyncAPITokenSecretName(project), Namespace: project.Namespace}, + Data: map[string][]byte{}, + } + reconciler := &SupabaseProjectReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(project).WithObjects(project, credentials, invalidToken).Build(), + Scheme: scheme, + } + + if _, err := reconciler.reconcileSecrets(context.Background(), project); err == nil { + t.Fatal("reconcileSecrets() unexpectedly accepted an invalid PowerSync API token") + } + updated := &supabasev1alpha1.SupabaseProject{} + if err := reconciler.Get(context.Background(), client.ObjectKeyFromObject(project), updated); err != nil { + t.Fatal(err) + } + condition := meta.FindStatusCondition(updated.Status.Conditions, supabasev1alpha1.ConditionTypeSecretsReady) + if condition == nil || condition.Status != metav1.ConditionFalse || condition.Reason != "ImplementationSecretFailed" { + t.Fatalf("SecretsReady condition = %#v, want safe implementation-secret failure", condition) + } + if strings.Contains(condition.Message, "fixture-api-token") { + t.Fatalf("API token value leaked into status: %q", condition.Message) + } +} + +func TestReconcilePowersyncSecretsDoesNotTakeOverForeignAPIToken(t *testing.T) { + t.Parallel() + + project := powersyncTokenTestProject("token-foreign") + scheme := newPowerSyncTestScheme(t) + storage := powersyncRoleSecret(project, "powersync-storage-password", "powersync_storage", "storage-stable") + replication := powersyncRoleSecret(project, "powersync-replication-password", "powersync_replication", "replication-stable") + controller := true + token := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretresources.PowersyncAPITokenSecretName(project), + Namespace: project.Namespace, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: "secrets.example/v1", Kind: "SecretManager", Name: "foreign", UID: "foreign-uid", Controller: &controller, + }}, + }, + Data: map[string][]byte{secretresources.PowersyncAPITokenSecretKey: []byte("foreign-api-token")}, + } + reconciler := &SupabaseProjectReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(project, storage, replication, token).Build(), + Scheme: scheme, + } + + err := reconciler.reconcilePowersyncSecrets(context.Background(), project, &supabasev1alpha1.SecretNamesStatus{}) + if err == nil || !strings.Contains(err.Error(), "already owned") { + t.Fatalf("foreign API token result = %v, want ownership error", err) + } + unchanged := &corev1.Secret{} + if err := reconciler.Get(context.Background(), client.ObjectKeyFromObject(token), unchanged); err != nil { + t.Fatal(err) + } + if len(unchanged.OwnerReferences) != 1 || unchanged.OwnerReferences[0].Name != "foreign" { + t.Fatalf("foreign API token ownership changed: %#v", unchanged.OwnerReferences) + } +} + +func TestReconcileImplementationSecretsDoesNotCreatePowerSyncTokenWhenDisabled(t *testing.T) { + t.Parallel() + + project := &supabasev1alpha1.SupabaseProject{ObjectMeta: metav1.ObjectMeta{Name: "token-disabled", Namespace: "default"}} + scheme := newPowerSyncTestScheme(t) + reconciler := &SupabaseProjectReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(project).Build(), + Scheme: scheme, + } + if err := reconciler.reconcileImplementationSecrets(context.Background(), project); err != nil { + t.Fatalf("reconcileImplementationSecrets() error = %v", err) + } + token := &corev1.Secret{} + err := reconciler.Get(context.Background(), types.NamespacedName{Name: secretresources.PowersyncAPITokenSecretName(project), Namespace: project.Namespace}, token) + if err == nil { + t.Fatal("disabled PowerSync unexpectedly created an API token Secret") + } +} + +func TestCreateOrUpdateDeploymentReplacesPowerSyncReplicationStrategy(t *testing.T) { + t.Parallel() + + project := powersyncTokenTestProject("strategy") + scheme := newPowerSyncTestScheme(t) + desired := deploymentresources.BuildPowersyncReplicationDeployment(project, &supabasev1alpha1.SecretNamesStatus{ + PowersyncStoragePassword: "storage", + PowersyncReplicationPassword: "replication", + }) + existing := desired.DeepCopy() + existing.Spec.Strategy = appsv1.DeploymentStrategy{ + Type: appsv1.RollingUpdateDeploymentStrategyType, + RollingUpdate: &appsv1.RollingUpdateDeployment{MaxUnavailable: ptr.To(intstr.FromString("25%"))}, + } + reconciler := &SupabaseProjectReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(project, existing).Build(), + Scheme: scheme, + } + if err := reconciler.createOrUpdateDeployment(context.Background(), project, desired); err != nil { + t.Fatalf("createOrUpdateDeployment() error = %v", err) + } + updated := &appsv1.Deployment{} + if err := reconciler.Get(context.Background(), client.ObjectKeyFromObject(existing), updated); err != nil { + t.Fatal(err) + } + if updated.Spec.Strategy.Type != appsv1.RecreateDeploymentStrategyType || updated.Spec.Strategy.RollingUpdate != nil { + t.Fatalf("updated replication strategy = %#v, want Recreate without rollingUpdate", updated.Spec.Strategy) + } +} + +func powersyncTokenTestProject(name string) *supabasev1alpha1.SupabaseProject { + return &supabasev1alpha1.SupabaseProject{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default", UID: types.UID(name + "-uid")}, + Spec: supabasev1alpha1.SupabaseProjectSpec{Powersync: &supabasev1alpha1.PowersyncSpec{}}, + } +} + +func powersyncRoleSecret(project *supabasev1alpha1.SupabaseProject, suffix, username, password string) *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: project.Name + "-" + suffix, Namespace: project.Namespace}, + Data: map[string][]byte{ + "username": []byte(username), + "password": []byte(password), + }, + } +} + +func assertPowerSyncRolePassword(t *testing.T, reconciler *SupabaseProjectReconciler, original *corev1.Secret, want string) { + t.Helper() + updated := &corev1.Secret{} + if err := reconciler.Get(context.Background(), client.ObjectKeyFromObject(original), updated); err != nil { + t.Fatal(err) + } + if got := string(updated.Data["password"]); got != want { + t.Fatalf("%s password = %q, want %q", original.Name, got, want) + } +} diff --git a/internal/controller/supabaseproject_controller.go b/internal/controller/supabaseproject_controller.go index d27af5b..693e7ed 100644 --- a/internal/controller/supabaseproject_controller.go +++ b/internal/controller/supabaseproject_controller.go @@ -450,34 +450,65 @@ func (r *SupabaseProjectReconciler) reconcilePowersyncSecrets(ctx context.Contex } 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.updateProjectStatus(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()) + } else { + // Generate PowerSync database role secrets. The create-once helper + // preserves any role Secret that was already present when only one role + // was missing. + 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.updateProjectStatus(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.updateProjectStatus(ctx, project); statusErr != nil { + return statusErr + } + return err + } + } } secretNames.PowersyncStoragePassword = storagePwdName secretNames.PowersyncReplicationPassword = replPwdName + return r.reconcilePowersyncAPITokenSecret(ctx, project) +} + +func (r *SupabaseProjectReconciler) reconcilePowersyncAPITokenSecret(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { + name := secrets.PowersyncAPITokenSecretName(project) + existing := &corev1.Secret{} + err := r.Get(ctx, types.NamespacedName{Name: name, Namespace: project.Namespace}, existing) + if err != nil { + if !apierrors.IsNotFound(err) { + return fmt.Errorf("getting PowerSync API token Secret %q: %w", name, err) + } + generated, err := secrets.GeneratePowersyncAPITokenSecret(project) + if err != nil { + return err + } + if err := r.createOrUpdateSecret(ctx, project, generated); err != nil { + return fmt.Errorf("creating PowerSync API token Secret %q: %w", name, err) + } + return nil + } + + if err := secrets.ValidatePowersyncAPITokenSecret(existing); err != nil { + return fmt.Errorf("validating PowerSync API token Secret %q: %w", name, err) + } + + // The create-once helper repairs a missing project owner and refuses to + // replace a Secret controlled by another resource. A placeholder carries no + // token data, so an existing valid token can never be overwritten. + placeholder := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: project.Namespace}} + if err := r.createOrUpdateSecret(ctx, project, placeholder); err != nil { + return fmt.Errorf("adopting PowerSync API token Secret %q: %w", name, err) + } return nil } @@ -1364,8 +1395,10 @@ func (r *SupabaseProjectReconciler) createOrUpdateDeployment(ctx context.Context normalizePodTemplateDefaults(&desired.Spec.Template) normalizePodTemplateDefaults(&actual.Spec.Template) ownerChanged := !apiequality.Semantic.DeepEqual(ownerRefs, existing.OwnerReferences) + strategyChanged := desired.Spec.Strategy.Type != "" && + !apiequality.Semantic.DeepEqual(actual.Spec.Strategy, desired.Spec.Strategy) if apiequality.Semantic.DeepEqual(actual.Spec.Replicas, desired.Spec.Replicas) && - apiequality.Semantic.DeepEqual(actual.Spec.Template, desired.Spec.Template) && !ownerChanged { + apiequality.Semantic.DeepEqual(actual.Spec.Template, desired.Spec.Template) && !ownerChanged && !strategyChanged { return nil } @@ -1373,6 +1406,9 @@ func (r *SupabaseProjectReconciler) createOrUpdateDeployment(ctx context.Context log.V(1).Info("Updating deployment", "name", deployment.Name) existing.Spec.Replicas = desired.Spec.Replicas existing.Spec.Template = desired.Spec.Template + if strategyChanged { + existing.Spec.Strategy = desired.Spec.Strategy + } return r.Update(ctx, existing) } diff --git a/internal/resources/configmaps/powersync.go b/internal/resources/configmaps/powersync.go index b80f6df..48ab975 100644 --- a/internal/resources/configmaps/powersync.go +++ b/internal/resources/configmaps/powersync.go @@ -43,8 +43,8 @@ func PowersyncSyncRulesConfigMapName(project *supabasev1alpha1.SupabaseProject) } // BuildPowersyncConfigMap creates the PowerSync config.yaml ConfigMap. -// PowerSync's !env tag resolves the database URIs at runtime without putting -// credentials in the ConfigMap. +// PowerSync's !env tag resolves the database URIs and administrative API token +// at runtime without putting credentials in the ConfigMap. func BuildPowersyncConfigMap(project *supabasev1alpha1.SupabaseProject) *corev1.ConfigMap { configYAML := fmt.Sprintf(`storage: type: postgresql @@ -63,6 +63,9 @@ client_auth: jwks_uri: %q audience: - authenticated +api: + tokens: + - !env PS_POWERSYNC_API_TOKEN migrations: disable_auto_migration: false port: 8080 @@ -70,6 +73,7 @@ sync_rules: path: /powersync/sync_rules/sync_rules.yaml exit_on_error: true telemetry: + prometheus_port: 9464 disable_telemetry_sharing: false `, common.AuthJWKSURL(project.Spec.Auth.ExternalURL)) diff --git a/internal/resources/configmaps/powersync_test.go b/internal/resources/configmaps/powersync_test.go index 9ed6611..9f9a331 100644 --- a/internal/resources/configmaps/powersync_test.go +++ b/internal/resources/configmaps/powersync_test.go @@ -34,10 +34,16 @@ type powersyncConfig struct { JWKSURI string `json:"jwks_uri"` Audience []string `json:"audience"` } `json:"client_auth"` + API struct { + Tokens []string `json:"tokens"` + } `json:"api"` SyncRules struct { Path string `json:"path"` ExitOnError bool `json:"exit_on_error"` } `json:"sync_rules"` + Telemetry struct { + PrometheusPort int32 `json:"prometheus_port"` + } `json:"telemetry"` } func newTestProject(namespace string) *supabasev1alpha1.SupabaseProject { @@ -172,6 +178,14 @@ func TestBuildPowersyncConfigMap(t *testing.T) { t.Errorf("audience = %v, want authenticated", config.ClientAuth.Audience) } + // Administrative API authentication + if len(config.API.Tokens) != 1 || config.API.Tokens[0] != "PS_POWERSYNC_API_TOKEN" { + t.Errorf("API tokens = %v, want the PS_POWERSYNC_API_TOKEN reference", config.API.Tokens) + } + if !strings.Contains(configYAML, "- !env PS_POWERSYNC_API_TOKEN") { + t.Error("API token must use PowerSync's !env tag") + } + // Sync rules path if config.SyncRules.Path != "/powersync/sync_rules/sync_rules.yaml" { t.Errorf("sync rules path = %q", config.SyncRules.Path) @@ -179,6 +193,10 @@ func TestBuildPowersyncConfigMap(t *testing.T) { if !config.SyncRules.ExitOnError { t.Error("sync rules must fail startup when invalid") } + + if config.Telemetry.PrometheusPort != 9464 { + t.Errorf("telemetry prometheus port = %d, want 9464", config.Telemetry.PrometheusPort) + } } func TestBuildPowersyncSyncRulesConfigMap_UsesSyncStreams(t *testing.T) { diff --git a/internal/resources/defaults/images.go b/internal/resources/defaults/images.go index ebd50c2..24040e7 100644 --- a/internal/resources/defaults/images.go +++ b/internal/resources/defaults/images.go @@ -28,5 +28,5 @@ const ( // Powersync image defaults PowersyncImage = "journeyapps/powersync-service" - PowersyncTag = "1.20.4" + PowersyncTag = "1.21.0" ) diff --git a/internal/resources/deployments/powersync.go b/internal/resources/deployments/powersync.go index c06fd57..9804537 100644 --- a/internal/resources/deployments/powersync.go +++ b/internal/resources/deployments/powersync.go @@ -30,6 +30,7 @@ import ( "github.com/GuionAI/cloudnative-supabase/internal/resources/common" "github.com/GuionAI/cloudnative-supabase/internal/resources/configmaps" "github.com/GuionAI/cloudnative-supabase/internal/resources/defaults" + "github.com/GuionAI/cloudnative-supabase/internal/resources/secrets" ) const ( @@ -193,6 +194,7 @@ func BuildPowersyncReplicationDeployment(project *supabasev1alpha1.SupabaseProje }, Spec: appsv1.DeploymentSpec{ Replicas: &replicas, + Strategy: appsv1.DeploymentStrategy{Type: appsv1.RecreateDeploymentStrategyType}, Selector: &metav1.LabelSelector{ MatchLabels: common.SelectorLabels(project, PowersyncReplicationComponentName), }, @@ -342,10 +344,22 @@ func buildPowersyncEnv(project *supabasev1alpha1.SupabaseProject, secretNames *s {Name: "POWERSYNC_CONFIG_PATH", Value: "/powersync/config/config.yaml"}, {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"}, + // PowerSync's administrative API token is shared with same-namespace + // monitoring through this stable Secret contract. + { + Name: "PS_POWERSYNC_API_TOKEN", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secrets.PowersyncAPITokenSecretName(project), + }, + Key: secrets.PowersyncAPITokenSecretKey, + }, + }, + }, // Storage password (powersync_storage role — internal sync state tables) { Name: "PS_STORAGE_PASSWORD", diff --git a/internal/resources/deployments/powersync_test.go b/internal/resources/deployments/powersync_test.go index 7df6c02..03369d2 100644 --- a/internal/resources/deployments/powersync_test.go +++ b/internal/resources/deployments/powersync_test.go @@ -7,6 +7,7 @@ import ( supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" "github.com/GuionAI/cloudnative-supabase/internal/resources/defaults" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" ) @@ -61,6 +62,9 @@ func TestBuildPowersyncAPIDeployment(t *testing.T) { if *dep.Spec.Replicas != 1 { t.Errorf("Replicas = %d, want 1", *dep.Spec.Replicas) } + if dep.Spec.Strategy.Type != "" || dep.Spec.Strategy.RollingUpdate != nil { + t.Fatalf("API strategy = %#v, want the existing rolling/default strategy", dep.Spec.Strategy) + } c := dep.Spec.Template.Spec.Containers[0] @@ -85,11 +89,14 @@ func TestBuildPowersyncAPIDeployment(t *testing.T) { if c.Ports[0].ContainerPort != PowersyncHTTPPort { t.Errorf("HTTP port = %d, want %d", c.Ports[0].ContainerPort, PowersyncHTTPPort) } + if c.Ports[1].Name != "metrics" { + t.Errorf("metrics port name = %q, want metrics", c.Ports[1].Name) + } if c.Ports[1].ContainerPort != PowersyncMetricsPort { t.Errorf("metrics port = %d, want %d", c.Ports[1].ContainerPort, PowersyncMetricsPort) } - // PowerSync 1.20 filesystem probes. + // PowerSync filesystem probes remain the ordinary availability contract. 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") @@ -164,6 +171,9 @@ func TestBuildPowersyncReplicationDeployment(t *testing.T) { if *dep.Spec.Replicas != 1 { t.Errorf("Replicas = %d, want 1 (replication must be single instance)", *dep.Spec.Replicas) } + if dep.Spec.Strategy.Type != appsv1.RecreateDeploymentStrategyType || dep.Spec.Strategy.RollingUpdate != nil { + t.Fatalf("replication strategy = %#v, want Recreate without rollingUpdate", dep.Spec.Strategy) + } c := dep.Spec.Template.Spec.Containers[0] @@ -172,7 +182,7 @@ func TestBuildPowersyncReplicationDeployment(t *testing.T) { } // Only metrics port (no HTTP) - if len(c.Ports) != 1 || c.Ports[0].ContainerPort != PowersyncMetricsPort { + if len(c.Ports) != 1 || c.Ports[0].Name != "metrics" || c.Ports[0].ContainerPort != PowersyncMetricsPort { t.Errorf("expected only metrics port %d", PowersyncMetricsPort) } assertFreshPowersyncLivenessProbe(t, c.LivenessProbe) @@ -290,12 +300,31 @@ func TestBuildPowersyncEnvVars(t *testing.T) { envMap[e.Name] = e.Value } - 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"} + required := []string{"POWERSYNC_CONFIG_PATH", "NODE_OPTIONS", "LOG_FORMAT", "MICRO_PROBE_TYPE", "PS_STORAGE_PASSWORD", "PS_REPLICATION_PASSWORD", "PS_POWERSYNC_API_TOKEN", "PS_POWERSYNC_STORAGE_URI", "PS_POWERSYNC_REPLICATION_URI"} for _, name := range required { if _, ok := envMap[name]; !ok { t.Errorf("missing required env var: %s", name) } } + if _, ok := envMap["METRICS_PORT"]; ok { + t.Error("METRICS_PORT must not override PowerSync's native telemetry configuration") + } + var tokenEnv *corev1.EnvVar + for i := range env { + if env[i].Name == "PS_POWERSYNC_API_TOKEN" { + tokenEnv = &env[i] + break + } + } + if tokenEnv == nil || tokenEnv.ValueFrom == nil || tokenEnv.ValueFrom.SecretKeyRef == nil { + t.Fatal("PowerSync API token must come from a SecretKeyRef") + } + if got := tokenEnv.ValueFrom.SecretKeyRef.Name; got != "my-app-powersync-api-token" { + t.Errorf("API token Secret = %q, want my-app-powersync-api-token", got) + } + if got := tokenEnv.ValueFrom.SecretKeyRef.Key; got != "token" { + t.Errorf("API token key = %q, want token", got) + } for _, env := range env { if env.Name == "POWERSYNC_CONFIG_PATH" && env.Value != "/powersync/config/config.yaml" { t.Errorf("POWERSYNC_CONFIG_PATH = %q, want config.yaml", env.Value) @@ -307,3 +336,53 @@ func TestBuildPowersyncEnvVars(t *testing.T) { } } } + +func TestBuildPowersyncTokenEnvForEveryManagedCommand(t *testing.T) { + project := newTestProject("default") + secretNames := newTestSecretNames() + containers := []corev1.Container{ + BuildPowersyncAPIDeployment(project, secretNames).Spec.Template.Spec.Containers[0], + BuildPowersyncReplicationDeployment(project, secretNames).Spec.Template.Spec.Containers[0], + BuildPowersyncCompactCronJob(project, secretNames).Spec.JobTemplate.Spec.Template.Spec.Containers[0], + } + + for _, container := range containers { + var tokenEnv *corev1.EnvVar + for i := range container.Env { + if container.Env[i].Name == "PS_POWERSYNC_API_TOKEN" { + tokenEnv = &container.Env[i] + break + } + } + if tokenEnv == nil || tokenEnv.ValueFrom == nil || tokenEnv.ValueFrom.SecretKeyRef == nil { + t.Fatalf("%s does not load the API token from a SecretKeyRef", container.Name) + } + if got := tokenEnv.ValueFrom.SecretKeyRef.Name; got != "my-app-powersync-api-token" { + t.Errorf("%s API token Secret = %q", container.Name, got) + } + if got := tokenEnv.ValueFrom.SecretKeyRef.Key; got != "token" { + t.Errorf("%s API token key = %q", container.Name, got) + } + } +} + +func TestPowersyncImageOverrideAppliesToEveryManagedCommand(t *testing.T) { + project := newTestProject("default") + project.Spec.Powersync.Image = supabasev1alpha1.ImageSpec{ + Registry: "registry.example", + Repository: "powersync/service", + Tag: "test", + } + secretNames := newTestSecretNames() + const expectedImage = "registry.example/powersync/service:test" + + if got := BuildPowersyncAPIDeployment(project, secretNames).Spec.Template.Spec.Containers[0].Image; got != expectedImage { + t.Errorf("API image = %q", got) + } + if got := BuildPowersyncReplicationDeployment(project, secretNames).Spec.Template.Spec.Containers[0].Image; got != expectedImage { + t.Errorf("replication image = %q", got) + } + if got := BuildPowersyncCompactCronJob(project, secretNames).Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image; got != expectedImage { + t.Errorf("compact image = %q", got) + } +} diff --git a/internal/resources/secrets/secrets.go b/internal/resources/secrets/secrets.go index 5122c94..57a8058 100644 --- a/internal/resources/secrets/secrets.go +++ b/internal/resources/secrets/secrets.go @@ -48,9 +48,16 @@ const ( // GoTrueFallbackSecretNameSuffix is appended to a project name for the // create-once fallback Secret. GoTrueFallbackSecretNameSuffix = "-gotrue-jwt-secret" + // PowersyncAPITokenSecretKey is the key containing the internal PowerSync + // administrative API token. + PowersyncAPITokenSecretKey = "token" + // PowersyncAPITokenSecretNameSuffix is appended to a project name for the + // create-once PowerSync administrative API token Secret. + PowersyncAPITokenSecretNameSuffix = "-powersync-api-token" ) const emailHookSecretBytes = 32 +const powersyncAPITokenBytes = 32 const ( opaqueKeyChecksumContext = "supabase-self-hosted" @@ -357,3 +364,43 @@ func GeneratePowersyncSecrets(project *supabasev1alpha1.SupabaseProject) ([]*cor func PowersyncSecretNames(project *supabasev1alpha1.SupabaseProject) (string, string) { return project.Name + "-powersync-storage-password", project.Name + "-powersync-replication-password" } + +// PowersyncAPITokenSecretName returns the create-once PowerSync administrative +// API token Secret name. +func PowersyncAPITokenSecretName(project *supabasev1alpha1.SupabaseProject) string { + return project.Name + PowersyncAPITokenSecretNameSuffix +} + +// GeneratePowersyncAPITokenSecret creates the internal PowerSync administrative +// API token Secret. The token is intentionally independent of project client +// credentials and database role passwords. +func GeneratePowersyncAPITokenSecret(project *supabasev1alpha1.SupabaseProject) (*corev1.Secret, error) { + token, err := crypto.GenerateHex(powersyncAPITokenBytes) + if err != nil { + return nil, fmt.Errorf("generating PowerSync API token: %w", err) + } + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: PowersyncAPITokenSecretName(project), + Namespace: project.Namespace, + Labels: common.ComponentLabels(project, "powersync-api-token"), + }, + Type: corev1.SecretTypeOpaque, + StringData: map[string]string{ + PowersyncAPITokenSecretKey: token, + }, + }, nil +} + +// ValidatePowersyncAPITokenSecret validates the non-secret API token contract. +// Error messages identify only the missing or invalid key, never its value. +func ValidatePowersyncAPITokenSecret(secret *corev1.Secret) error { + if secret == nil { + return fmt.Errorf("PowerSync API token Secret is nil") + } + value, ok := secretValue(secret, PowersyncAPITokenSecretKey) + if !ok || strings.TrimSpace(value) == "" { + return fmt.Errorf("PowerSync API token Secret is missing required non-empty key %q", PowersyncAPITokenSecretKey) + } + return nil +} diff --git a/internal/resources/secrets/secrets_test.go b/internal/resources/secrets/secrets_test.go index f9e6144..a21fd34 100644 --- a/internal/resources/secrets/secrets_test.go +++ b/internal/resources/secrets/secrets_test.go @@ -98,3 +98,46 @@ func TestGeneratePowersyncSecrets(t *testing.T) { t.Error("storage and replication passwords must differ") } } + +func TestPowersyncAPITokenSecretContract(t *testing.T) { + project := newTestProject("test-ns") + + if got := PowersyncAPITokenSecretName(project); got != "my-app-powersync-api-token" { + t.Fatalf("PowersyncAPITokenSecretName() = %q, want my-app-powersync-api-token", got) + } + + secret, err := GeneratePowersyncAPITokenSecret(project) + if err != nil { + t.Fatalf("GeneratePowersyncAPITokenSecret() error = %v", err) + } + if secret.Name != "my-app-powersync-api-token" || secret.Namespace != "test-ns" { + t.Fatalf("secret metadata = %s/%s", secret.Namespace, secret.Name) + } + token := secret.StringData[PowersyncAPITokenSecretKey] + if len(token) != 64 { + t.Fatalf("generated token length = %d, want 64 hex characters", len(token)) + } + if err := ValidatePowersyncAPITokenSecret(secret); err != nil { + t.Fatalf("generated token failed validation: %v", err) + } +} + +func TestValidatePowersyncAPITokenSecretRejectsMissingOrEmptyKey(t *testing.T) { + for name, secret := range map[string]*corev1.Secret{ + "nil": nil, + "absent": {Data: map[string][]byte{}}, + "empty": {Data: map[string][]byte{PowersyncAPITokenSecretKey: nil}}, + "space": {StringData: map[string]string{PowersyncAPITokenSecretKey: " "}}, + } { + t.Run(name, func(t *testing.T) { + if err := ValidatePowersyncAPITokenSecret(secret); err == nil { + t.Fatal("ValidatePowersyncAPITokenSecret() unexpectedly succeeded") + } + }) + } + + valid := &corev1.Secret{Data: map[string][]byte{PowersyncAPITokenSecretKey: []byte("fixture-token")}} + if err := ValidatePowersyncAPITokenSecret(valid); err != nil { + t.Fatalf("valid token failed validation: %v", err) + } +} diff --git a/internal/resources/services/services_test.go b/internal/resources/services/services_test.go index 48737bf..9cb6eaa 100644 --- a/internal/resources/services/services_test.go +++ b/internal/resources/services/services_test.go @@ -21,7 +21,8 @@ func TestBuildPowersyncAPIService(t *testing.T) { 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 { + if len(svc.Spec.Ports) != 2 || svc.Spec.Ports[0].Name != "http" || svc.Spec.Ports[0].Port != 8080 || + svc.Spec.Ports[1].Name != "metrics" || svc.Spec.Ports[1].Port != 9464 { t.Errorf("unexpected ports: %v", svc.Spec.Ports) } if svc.Spec.Selector["app.kubernetes.io/component"] != "powersync-api" {