diff --git a/server/internal/api/apiv1/validate.go b/server/internal/api/apiv1/validate.go index 947d94bc..78a040ba 100644 --- a/server/internal/api/apiv1/validate.go +++ b/server/internal/api/apiv1/validate.go @@ -41,6 +41,28 @@ func validateAuthFileGUCs(conf map[string]any, path validation.Path) []error { return errs } +func validateConfLibraries(conf map[string]any, path validation.Path) []error { + // param names are case-insensitive, so comparison is done after casting to lowercase + for key, val := range conf { + if strings.ToLower(strings.TrimSpace(key)) != "shared_preload_libraries" { + continue + } + strVal, ok := val.(string) + if !ok { + err := fmt.Errorf("%q must be a string", key) + return []error{validation.NewError(err, path.AppendMapKey(key))} + } + for _, lib := range strings.Split(strVal, ",") { + if strings.TrimSpace(lib) == "spock" { + return nil + } + } + err := errors.New(`"spock" must be included in shared_preload_libraries`) + return []error{validation.NewError(err, path.AppendMapKey(key))} + } + return nil // key absent; defaults will be applied, including spock +} + // validatePgHbaConf checks that every non-comment pg_hba_conf entry parses. // Blank and comment lines are allowed and skipped. Validation is intentionally // minimal — see server/internal/postgres/hba/parse.go. @@ -134,8 +156,9 @@ func validateDatabaseSpec(orchestrator config.Orchestrator, databaseID string, s } // Reject postgresql_conf GUCs that would make user-supplied pg_hba/pg_ident - // entries ineffective, then validate the entries themselves. + // entries ineffective or remove spock, then validate the entries themselves. errs = append(errs, validateAuthFileGUCs(spec.PostgresqlConf, validation.NewPath("postgresql_conf"))...) + errs = append(errs, validateConfLibraries(spec.PostgresqlConf, validation.NewPath("postgresql_conf"))...) errs = append(errs, validatePgHbaConf(spec.PgHbaConf, validation.NewPath("pg_hba_conf"))...) errs = append(errs, validatePgIdentConf(spec.PgIdentConf, validation.NewPath("pg_ident_conf"))...) @@ -234,6 +257,14 @@ func validateDatabaseUpdate(old *database.Spec, new *api.DatabaseSpec) error { errs = append(errs, validateServiceSpec(svc, svcPath, isExistingService, old.DatabaseID, new.DatabaseUsers, newNodeNames)...) } + // Validate that shared_preload_libraries, if explicitly set, still includes + // "spock" — both at the spec level and on individual nodes. + errs = append(errs, validateConfLibraries(new.PostgresqlConf, validation.NewPath("postgresql_conf"))...) + for i, n := range new.Nodes { + nodePath := validation.NewPath("nodes", validation.ArrayIndexElement(i)) + errs = append(errs, validateConfLibraries(n.PostgresqlConf, nodePath.Append("postgresql_conf"))...) + } + return errors.Join(errs...) } @@ -281,6 +312,7 @@ func validateNode( } errs = append(errs, validateAuthFileGUCs(node.PostgresqlConf, path.Append("postgresql_conf"))...) + errs = append(errs, validateConfLibraries(node.PostgresqlConf, path.Append("postgresql_conf"))...) errs = append(errs, validatePgHbaConf(node.PgHbaConf, path.Append("pg_hba_conf"))...) errs = append(errs, validatePgIdentConf(node.PgIdentConf, path.Append("pg_ident_conf"))...) diff --git a/server/internal/api/apiv1/validate_test.go b/server/internal/api/apiv1/validate_test.go index b43aa701..5f86959b 100644 --- a/server/internal/api/apiv1/validate_test.go +++ b/server/internal/api/apiv1/validate_test.go @@ -451,6 +451,92 @@ func TestValidateBackupConfig(t *testing.T) { } } +func TestValidateDatabaseUpdate_LibraryConf(t *testing.T) { + for _, tc := range []struct { + name string + old *database.Spec + new *api.DatabaseSpec + expected []string + }{ + { + name: "absent valid", + old: &database.Spec{}, + new: &api.DatabaseSpec{}, + }, + { + name: "invalid spec-level conf missing spock", + old: &database.Spec{}, + new: &api.DatabaseSpec{ + PostgresqlConf: map[string]any{"shared_preload_libraries": "pg_stat_statements"}, + }, + expected: []string{`"spock" must be included in shared_preload_libraries`}, + }, + { + name: "invalid node-level conf missing spock", + old: &database.Spec{}, + new: &api.DatabaseSpec{ + Nodes: []*api.DatabaseNodeSpec{ + {PostgresqlConf: map[string]any{"shared_preload_libraries": "pg_stat_statements"}}, + }, + }, + expected: []string{`"spock" must be included in shared_preload_libraries`}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + err := validateDatabaseUpdate(tc.old, tc.new) + if len(tc.expected) < 1 { + assert.NoError(t, err) + } else { + for _, expected := range tc.expected { + assert.ErrorContains(t, err, expected) + } + } + }) + } +} + +func TestValidateConfLibraries(t *testing.T) { + for _, tc := range []struct { + name string + conf map[string]any + expected []string + }{ + { + name: "absent valid", + }, + { + name: "valid spock conf", + conf: map[string]any{"shared_preload_libraries": "snowflake, spock"}, + }, + { + name: "valid uppercase param name", + conf: map[string]any{"SHARED_PRELOAD_LIBRARIES": "snowflake, spock"}, + }, + { + name: "valid conf spock and custom", + conf: map[string]any{"shared_preload_libraries": "snowflake, spock, custom_lib"}, + }, + { + name: "invalid conf missing spock", + conf: map[string]any{"shared_preload_libraries": "snowflake"}, + expected: []string{ + `"spock" must be included in shared_preload_libraries`, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + err := errors.Join(validateConfLibraries(tc.conf, nil)...) + if len(tc.expected) < 1 { + assert.NoError(t, err) + } else { + for _, expected := range tc.expected { + assert.ErrorContains(t, err, expected) + } + } + }) + } +} + func TestValidateNode(t *testing.T) { for _, tc := range []struct { name string @@ -574,6 +660,20 @@ func TestValidateNode(t *testing.T) { "restore_config.repository.base_path: base_path must be absolute for posix repositories", }, }, + { + name: "invalid lib conf", + orchestrator: config.OrchestratorSwarm, + db: &api.DatabaseSpec{}, + node: &api.DatabaseNodeSpec{ + HostIds: []api.Identifier{ + api.Identifier("host-1"), + }, + PostgresqlConf: map[string]any{"shared_preload_libraries": "snowflake"}, + }, + expected: []string{ + `"spock" must be included in shared_preload_libraries`, + }, + }, } { t.Run(tc.name, func(t *testing.T) { err := errors.Join(validateNode(tc.orchestrator, tc.db, tc.node, nil)...) @@ -1307,6 +1407,23 @@ func TestValidateDatabaseSpec(t *testing.T) { `nodes[0].postgresql_conf[HBA_FILE]: "HBA_FILE" is not allowed`, }, }, + { + name: "invalid lib conf", + spec: &api.DatabaseSpec{ + Nodes: []*api.DatabaseNodeSpec{ + { + Name: "n1", + HostIds: []api.Identifier{ + api.Identifier("host-1"), + }, + PostgresqlConf: map[string]any{"shared_preload_libraries": "snowflake"}, + }, + }, + }, + expected: []string{ + `"spock" must be included in shared_preload_libraries`, + }, + }, } { t.Run(tc.name, func(t *testing.T) { err := validateDatabaseSpec(config.OrchestratorSwarm, "test-db", tc.spec)