Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions internal/controller/powersync_lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,11 @@ func TestApplyPowerSyncConfigHashChangesPodTemplate(t *testing.T) {
if hashB := deploymentB.Spec.Template.Annotations[powerSyncConfigHashAnnotation]; hashB == hashA {
t.Fatal("sync rule changes must change the pod template hash")
}
deploymentC := &appsv1.Deployment{}
applyPowerSyncConfigHash(deploymentC, "changed-config", []byte("rules-a"))
if hashC := deploymentC.Spec.Template.Annotations[powerSyncConfigHashAnnotation]; hashC == hashA {
t.Fatal("generated config changes must change the pod template hash")
}
}

func TestMapExternalPowerSyncConfigMapToProjects(t *testing.T) {
Expand Down
4 changes: 2 additions & 2 deletions internal/controller/supabaseproject_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -2039,7 +2039,7 @@ func (r *SupabaseProjectReconciler) reconcilePowersync(ctx context.Context, proj

// Deploy Powersync API
apiDeployment := deployments.BuildPowersyncAPIDeployment(project, secretNames)
applyPowerSyncConfigHash(apiDeployment, psConfig.Data["config.json"], syncRulesContent)
applyPowerSyncConfigHash(apiDeployment, psConfig.Data[configmaps.PowersyncConfigKey], syncRulesContent)
if err := r.createOrUpdateDeployment(ctx, project, apiDeployment); err != nil {
r.setCondition(project, supabasev1alpha1.ConditionTypePowersyncReady, metav1.ConditionFalse, "APIDeploymentFailed", err.Error())
if statusErr := r.updateProjectStatus(ctx, project); statusErr != nil {
Expand All @@ -2060,7 +2060,7 @@ func (r *SupabaseProjectReconciler) reconcilePowersync(ctx context.Context, proj

// Deploy Powersync Replication
replDeployment := deployments.BuildPowersyncReplicationDeployment(project, secretNames)
applyPowerSyncConfigHash(replDeployment, psConfig.Data["config.json"], syncRulesContent)
applyPowerSyncConfigHash(replDeployment, psConfig.Data[configmaps.PowersyncConfigKey], syncRulesContent)
if err := r.createOrUpdateDeployment(ctx, project, replDeployment); err != nil {
r.setCondition(project, supabasev1alpha1.ConditionTypePowersyncReady, metav1.ConditionFalse, "ReplicationDeploymentFailed", err.Error())
if statusErr := r.updateProjectStatus(ctx, project); statusErr != nil {
Expand Down
117 changes: 30 additions & 87 deletions internal/resources/configmaps/powersync.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ limitations under the License.
package configmaps

import (
"encoding/json"
"fmt"

corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
Expand All @@ -29,6 +29,7 @@ import (
const (
PowersyncConfigComponentName = "powersync-config"
PowersyncSyncRulesComponentName = "powersync-sync-rules"
PowersyncConfigKey = "config.yaml"
)

// PowersyncConfigMapName returns the Powersync config ConfigMap name
Expand All @@ -41,92 +42,34 @@ func PowersyncSyncRulesConfigMapName(project *supabasev1alpha1.SupabaseProject)
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 is kept false so PowerSync does not enable its legacy HMAC
// integration. JWTs are verified through the public JWKS URI instead.
Supabase bool `json:"supabase"`
JWKSURI string `json:"jwks_uri"`
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.
// BuildPowersyncConfigMap creates the PowerSync config.yaml ConfigMap.
// PowerSync's !env tag resolves the database URIs at runtime without putting
// credentials in the ConfigMap.
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: false,
JWKSURI: common.AuthJWKSURL(project.Spec.Auth.ExternalURL),
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, "", " ")
configYAML := fmt.Sprintf(`storage:
type: postgresql
uri: !env PS_POWERSYNC_STORAGE_URI
replication:
connections:
- type: postgresql
uri: !env PS_POWERSYNC_REPLICATION_URI
tag: default
dev:
demo_auth: false
client_auth:
supabase: false
jwks_uri: %q
audience:
- authenticated
migrations:
disable_auto_migration: false
port: 8080
sync_rules:
path: /powersync/sync_rules/sync_rules.yaml
exit_on_error: true
telemetry:
disable_telemetry_sharing: false
`, common.AuthJWKSURL(project.Spec.Auth.ExternalURL))

return &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Expand All @@ -135,7 +78,7 @@ func BuildPowersyncConfigMap(project *supabasev1alpha1.SupabaseProject) *corev1.
Labels: common.ComponentLabels(project, PowersyncConfigComponentName),
},
Data: map[string]string{
"config.json": string(configJSON),
PowersyncConfigKey: configYAML,
},
}
}
Expand Down
46 changes: 38 additions & 8 deletions internal/resources/configmaps/powersync_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package configmaps

import (
"encoding/json"
"strings"
"testing"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/yaml"

supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1"
)
Expand All @@ -15,6 +15,29 @@ const (
testNamespace = "test-ns"
)

type powersyncConfig struct {
Storage struct {
Type string `json:"type"`
URI string `json:"uri"`
} `json:"storage"`
Replication struct {
Connections []struct {
Type string `json:"type"`
URI string `json:"uri"`
Tag string `json:"tag"`
} `json:"connections"`
} `json:"replication"`
ClientAuth struct {
Supabase bool `json:"supabase"`
JWKSURI string `json:"jwks_uri"`
Audience []string `json:"audience"`
} `json:"client_auth"`
SyncRules struct {
Path string `json:"path"`
ExitOnError bool `json:"exit_on_error"`
} `json:"sync_rules"`
}

func newTestProject(namespace string) *supabasev1alpha1.SupabaseProject {
return &supabasev1alpha1.SupabaseProject{
ObjectMeta: metav1.ObjectMeta{
Expand Down Expand Up @@ -89,24 +112,28 @@ func TestBuildPowersyncConfigMap(t *testing.T) {
t.Errorf("Namespace = %q, want %q", cm.Namespace, testNamespace)
}

configJSON, ok := cm.Data["config.json"]
configYAML, ok := cm.Data["config.yaml"]
if !ok {
t.Fatal("config.json key not found")
t.Fatal("config.yaml key not found")
}

// Parse the JSON to validate structure
// Parse the YAML to validate structure. The raw assertions below preserve
// the !env tags that PowerSync resolves before decoding the values.
var config powersyncConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
t.Fatalf("invalid JSON: %v", err)
if err := yaml.Unmarshal([]byte(configYAML), &config); err != nil {
t.Fatalf("invalid YAML: %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 }}" {
if config.Storage.URI != "PS_POWERSYNC_STORAGE_URI" {
t.Errorf("storage URI = %q, want environment template", config.Storage.URI)
}
if !strings.Contains(configYAML, "uri: !env PS_POWERSYNC_STORAGE_URI") {
t.Error("storage URI must use PowerSync's !env tag")
}

// Replication
if len(config.Replication.Connections) != 1 {
Expand All @@ -119,9 +146,12 @@ func TestBuildPowersyncConfigMap(t *testing.T) {
if conn.Tag != "default" {
t.Errorf("connection tag = %q, want default", conn.Tag)
}
if conn.URI != "{{ env.PS_POWERSYNC_REPLICATION_URI }}" {
if conn.URI != "PS_POWERSYNC_REPLICATION_URI" {
t.Errorf("replication URI = %q, want environment template", conn.URI)
}
if !strings.Contains(configYAML, "uri: !env PS_POWERSYNC_REPLICATION_URI") {
t.Error("replication URI must use PowerSync's !env tag")
}

// Client auth
if config.ClientAuth.Supabase {
Expand Down
4 changes: 2 additions & 2 deletions internal/resources/deployments/powersync.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ func buildPowersyncEnv(project *supabasev1alpha1.SupabaseProject, secretNames *s
dbHost := cnpg.ClusterRWServiceName(project)

return []corev1.EnvVar{
{Name: "POWERSYNC_CONFIG_PATH", Value: "/powersync/config/config.json"},
{Name: "POWERSYNC_CONFIG_PATH", Value: "/powersync/config/config.yaml"},
{Name: "NODE_OPTIONS", Value: nodeOptions},
{Name: "LOG_FORMAT", Value: "json"},
{Name: "METRICS_PORT", Value: "9464"},
Expand Down Expand Up @@ -370,7 +370,7 @@ func buildPowersyncEnv(project *supabasev1alpha1.SupabaseProject, secretNames *s
},
},
},
// PowerSync resolves {{ env.VAR }} in config.json
// PowerSync resolves !env PS_* tags in config.yaml.
{
Name: "PS_POWERSYNC_STORAGE_URI",
Value: fmt.Sprintf("postgresql://powersync_storage:$(PS_STORAGE_PASSWORD)@%s:5432/supabase?sslmode=disable", dbHost),
Expand Down
5 changes: 5 additions & 0 deletions internal/resources/deployments/powersync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,4 +295,9 @@ func TestBuildPowersyncEnvVars(t *testing.T) {
t.Errorf("missing required env var: %s", name)
}
}
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)
}
}
}
Loading