diff --git a/internal/db/start/start.go b/internal/db/start/start.go index 826d46c86..a300f5594 100644 --- a/internal/db/start/start.go +++ b/internal/db/start/start.go @@ -56,7 +56,6 @@ func NewContainerConfig() container.Config { env := []string{ "POSTGRES_PASSWORD=" + utils.Config.Db.Password, "POSTGRES_HOST=/var/run/postgresql", - "POSTGRES_INITDB_ARGS=--lc-ctype=C.UTF-8", "JWT_SECRET=" + utils.Config.Auth.JwtSecret, fmt.Sprintf("JWT_EXP=%d", utils.Config.Auth.JwtExpiry), } @@ -81,13 +80,18 @@ func NewContainerConfig() container.Config { Timeout: 2 * time.Second, Retries: 3, }, - Entrypoint: []string{"sh", "-c", `cat <<'EOF' > /etc/postgresql.schema.sql && cat <<'EOF' > /etc/postgresql-custom/pgsodium_root.key && docker-entrypoint.sh postgres -D /etc/postgresql + Entrypoint: []string{"sh", "-c", ` +cat <<'EOF' > /etc/postgresql.schema.sql && \ +cat <<'EOF' > /etc/postgresql-custom/pgsodium_root.key && \ +cat <<'EOF' >> /etc/postgresql/postgresql.conf && \ +docker-entrypoint.sh postgres -D /etc/postgresql ` + initialSchema + ` ` + _supabaseSchema + ` EOF ` + utils.Config.Db.RootKey + ` EOF -`}, +` + utils.Config.Db.Settings.ToPostgresConfig() + ` +EOF`}, } if utils.Config.Db.MajorVersion >= 14 { config.Cmd = []string{"postgres", @@ -124,11 +128,13 @@ func StartDatabase(ctx context.Context, fsys afero.Fs, w io.Writer, options ...f } if utils.Config.Db.MajorVersion <= 14 { config.Entrypoint = []string{"sh", "-c", ` - cat <<'EOF' > /docker-entrypoint-initdb.d/supabase_schema.sql +cat <<'EOF' > /docker-entrypoint-initdb.d/supabase_schema.sql && \ +cat <<'EOF' >> /etc/postgresql/postgresql.conf && \ +docker-entrypoint.sh postgres -D /etc/postgresql ` + _supabaseSchema + ` EOF - docker-entrypoint.sh postgres -D /etc/postgresql - `} +` + utils.Config.Db.Settings.ToPostgresConfig() + ` +EOF`} hostConfig.Tmpfs = map[string]string{"/docker-entrypoint-initdb.d": ""} } // Creating volume will not override existing volume, so we must inspect explicitly diff --git a/internal/db/start/start_test.go b/internal/db/start/start_test.go index 96d97da8c..475562f2f 100644 --- a/internal/db/start/start_test.go +++ b/internal/db/start/start_test.go @@ -17,6 +17,7 @@ import ( "github.com/supabase/cli/internal/testing/apitest" "github.com/supabase/cli/internal/testing/fstest" "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/pkg/cast" "github.com/supabase/cli/pkg/pgtest" ) @@ -308,3 +309,55 @@ func TestSetupDatabase(t *testing.T) { assert.Empty(t, apitest.ListUnmatchedRequests()) }) } +func TestStartDatabaseWithCustomSettings(t *testing.T) { + t.Run("starts database with custom MaxConnections", func(t *testing.T) { + // Setup + utils.Config.Db.MajorVersion = 15 + utils.DbId = "supabase_db_test" + utils.ConfigId = "supabase_config_test" + utils.Config.Db.Port = 5432 + utils.Config.Db.Settings.MaxConnections = cast.Ptr(uint(50)) + + // Setup in-memory fs + fsys := afero.NewMemMapFs() + + // Setup mock docker + require.NoError(t, apitest.MockDocker(utils.Docker)) + defer gock.OffAll() + gock.New(utils.Docker.DaemonHost()). + Get("/v" + utils.Docker.ClientVersion() + "/volumes/" + utils.DbId). + Reply(http.StatusNotFound). + JSON(volume.Volume{}) + apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Db.Image), utils.DbId) + gock.New(utils.Docker.DaemonHost()). + Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/json"). + Reply(http.StatusOK). + JSON(types.ContainerJSON{ContainerJSONBase: &types.ContainerJSONBase{ + State: &types.ContainerState{ + Running: true, + Health: &types.Health{Status: types.Healthy}, + }, + }}) + + apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Realtime.Image), "test-realtime") + require.NoError(t, apitest.MockDockerLogs(utils.Docker, "test-realtime", "")) + apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Storage.Image), "test-storage") + require.NoError(t, apitest.MockDockerLogs(utils.Docker, "test-storage", "")) + apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Auth.Image), "test-auth") + require.NoError(t, apitest.MockDockerLogs(utils.Docker, "test-auth", "")) + // Setup mock postgres + conn := pgtest.NewConn() + defer conn.Close(t) + + // Run test + err := StartDatabase(context.Background(), fsys, io.Discard, conn.Intercept) + + // Check error + assert.NoError(t, err) + assert.Empty(t, apitest.ListUnmatchedRequests()) + + // Check if the custom MaxConnections setting was applied + config := NewContainerConfig() + assert.Contains(t, config.Entrypoint[2], "max_connections = 50") + }) +} diff --git a/pkg/config/db.go b/pkg/config/db.go index 89e5bfd24..e7c5f820b 100644 --- a/pkg/config/db.go +++ b/pkg/config/db.go @@ -1,6 +1,8 @@ package config import ( + "bytes" + "github.com/google/go-cmp/cmp" v1API "github.com/supabase/cli/pkg/api" "github.com/supabase/cli/pkg/cast" @@ -146,6 +148,17 @@ func (a *settings) fromRemoteConfig(remoteConfig v1API.PostgresConfigResponse) s return result } +const pgConfHeader = "\n# supabase [db.settings] configuration\n" + +// create a valid string to append to /etc/postgresql/postgresql.conf +func (a *settings) ToPostgresConfig() string { + // Assuming postgres settings is always a flat struct, we can serialise + // using toml, then replace double quotes with single. + data, _ := ToTomlBytes(*a) + body := bytes.ReplaceAll(data, []byte{'"'}, []byte{'\''}) + return pgConfHeader + string(body) +} + func (a *settings) DiffWithRemote(remoteConfig v1API.PostgresConfigResponse) ([]byte, error) { // Convert the config values into easily comparable remoteConfig values currentValue, err := ToTomlBytes(a) diff --git a/pkg/config/db_test.go b/pkg/config/db_test.go index e7c573475..8d70ec21b 100644 --- a/pkg/config/db_test.go +++ b/pkg/config/db_test.go @@ -153,3 +153,41 @@ func TestDbSettingsDiffWithRemote(t *testing.T) { assert.Contains(t, string(diff), "-shared_buffers = \"1GB\"") }) } + +func TestSettingsToPostgresConfig(t *testing.T) { + t.Run("Only set values should appear", func(t *testing.T) { + settings := settings{ + MaxConnections: cast.Ptr(uint(100)), + MaxLocksPerTransaction: cast.Ptr(uint(64)), + SharedBuffers: cast.Ptr("128MB"), + WorkMem: cast.Ptr("4MB"), + } + got := settings.ToPostgresConfig() + + assert.Contains(t, got, "max_connections = 100") + assert.Contains(t, got, "max_locks_per_transaction = 64") + assert.Contains(t, got, "shared_buffers = '128MB'") + assert.Contains(t, got, "work_mem = '4MB'") + + assert.NotContains(t, got, "effective_cache_size") + assert.NotContains(t, got, "maintenance_work_mem") + assert.NotContains(t, got, "max_parallel_workers") + }) + + t.Run("SessionReplicationRole should be handled correctly", func(t *testing.T) { + settings := settings{ + SessionReplicationRole: cast.Ptr(SessionReplicationRoleOrigin), + } + got := settings.ToPostgresConfig() + + assert.Contains(t, got, "session_replication_role = 'origin'") + }) + + t.Run("Empty settings should result in empty string", func(t *testing.T) { + settings := settings{} + got := settings.ToPostgresConfig() + + assert.Equal(t, got, "\n# supabase [db.settings] configuration\n") + assert.NotContains(t, got, "=") + }) +}