Skip to content

Commit cd206a1

Browse files
authored
Validate Postres config (#156)
* Validate config before applying * Cleanup * Add extension verification * Fleshing things out * lint fixes
1 parent 40dcfae commit cd206a1

File tree

4 files changed

+233
-9
lines changed

4 files changed

+233
-9
lines changed

internal/api/handle_admin.go

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -150,15 +150,14 @@ func handleUpdatePostgresSettings(w http.ResponseWriter, r *http.Request) {
150150
return
151151
}
152152

153+
// Logistical PG setting validations.
154+
requestedChanges, err = node.PGConfig.Validate(r.Context(), conn, requestedChanges)
155+
if err != nil {
156+
renderErr(w, err)
157+
return
158+
}
159+
153160
for k, v := range requestedChanges {
154-
exists, err := admin.SettingExists(r.Context(), conn, k)
155-
if err != nil {
156-
renderErr(w, err)
157-
}
158-
if !exists {
159-
renderErr(w, fmt.Errorf("invalid config option: %s", k))
160-
return
161-
}
162161
cfg[k] = v
163162
}
164163

internal/flypg/admin/admin.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,15 @@ func SettingExists(ctx context.Context, pg *pgx.Conn, setting string) (bool, err
337337
return out, nil
338338
}
339339

340+
func ExtensionAvailable(ctx context.Context, pg *pgx.Conn, extension string) (bool, error) {
341+
sql := fmt.Sprintf("SELECT EXISTS(SELECT 1 FROM pg_available_extensions WHERE name='%s')", extension)
342+
var out bool
343+
if err := pg.QueryRow(ctx, sql).Scan(&out); err != nil {
344+
return false, err
345+
}
346+
return out, nil
347+
}
348+
340349
func SettingRequiresRestart(ctx context.Context, pg *pgx.Conn, setting string) (bool, error) {
341350
sql := fmt.Sprintf("SELECT pending_restart FROM pg_settings WHERE name='%s'", setting)
342351
row := pg.QueryRow(ctx, sql)
@@ -370,3 +379,33 @@ func GetSetting(ctx context.Context, pg *pgx.Conn, setting string) (*PGSetting,
370379
}
371380
return &out, nil
372381
}
382+
383+
func ValidatePGSettings(ctx context.Context, conn *pgx.Conn, requested map[string]interface{}) error {
384+
for k, v := range requested {
385+
exists, err := SettingExists(ctx, conn, k)
386+
if err != nil {
387+
return fmt.Errorf("failed to verify setting: %s", err)
388+
}
389+
if !exists {
390+
return fmt.Errorf("setting %v is not a valid config option", k)
391+
}
392+
393+
// Verify specified extensions are installed
394+
if k == "shared_preload_libraries" {
395+
extensions := strings.Trim(v.(string), "'")
396+
extSlice := strings.Split(extensions, ",")
397+
for _, e := range extSlice {
398+
available, err := ExtensionAvailable(ctx, conn, e)
399+
if err != nil {
400+
return fmt.Errorf("failed to verify pg extension %s: %s", e, err)
401+
}
402+
403+
if !available {
404+
return fmt.Errorf("extension %s has not been installed within this image", e)
405+
}
406+
}
407+
}
408+
}
409+
410+
return nil
411+
}

internal/flypg/pg.go

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package flypg
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"io"
89
"log"
@@ -167,7 +168,7 @@ func (c *PGConfig) SetDefaults() error {
167168
"wal_level": "replica",
168169
"wal_log_hints": true,
169170
"hot_standby": true,
170-
"archive_mode": true,
171+
"archive_mode": "on",
171172
"archive_command": "'/bin/true'",
172173
"shared_preload_libraries": fmt.Sprintf("'%s'", strings.Join(sharedPreloadLibraries, ",")),
173174
}
@@ -273,6 +274,75 @@ func (c *PGConfig) writePasswordFile(pwd string) error {
273274
return nil
274275
}
275276

277+
func (c *PGConfig) Validate(ctx context.Context, conn *pgx.Conn, requested ConfigMap) (ConfigMap, error) {
278+
if err := admin.ValidatePGSettings(ctx, conn, requested); err != nil {
279+
return requested, err
280+
}
281+
282+
return c.validateCompatibility(requested)
283+
}
284+
285+
func (c *PGConfig) validateCompatibility(requested ConfigMap) (ConfigMap, error) {
286+
current, err := c.CurrentConfig()
287+
if err != nil {
288+
return requested, fmt.Errorf("failed to resolve current config: %s", err)
289+
}
290+
291+
// Shared preload libraries
292+
if v, ok := requested["shared_preload_libraries"]; ok {
293+
val := v.(string)
294+
295+
// Remove any formatting that may be applied
296+
val = strings.Trim(val, "'")
297+
val = strings.TrimSpace(val)
298+
if val == "" {
299+
return requested, errors.New("`shared_preload_libraries` must contain the `repmgr` extension")
300+
}
301+
302+
// Confirm repmgr is specified
303+
repmgrPresent := false
304+
entries := strings.Split(val, ",")
305+
for _, entry := range entries {
306+
if entry == "repmgr" {
307+
repmgrPresent = true
308+
break
309+
}
310+
}
311+
312+
if !repmgrPresent {
313+
return requested, errors.New("`shared_preload_libraries` must contain the `repmgr` extension")
314+
}
315+
316+
// Reconstruct value with proper formatting
317+
requested["shared_preload_libraries"] = fmt.Sprintf("'%s'", val)
318+
}
319+
320+
// Wal-level
321+
if v, ok := requested["wal_level"]; ok {
322+
value := v.(string)
323+
324+
// Postgres will not boot properly if minimal is set with archive_mode enabled.
325+
if value == "minimal" {
326+
valid := false
327+
if val, ok := requested["archive_mode"]; ok {
328+
if val == "off" {
329+
valid = true
330+
}
331+
}
332+
333+
if !valid && current["archive_mode"] == "off" {
334+
valid = true
335+
}
336+
337+
if !valid {
338+
return requested, errors.New("archive_mode must be set to `off` before wal_level can be set to `minimal`")
339+
}
340+
}
341+
}
342+
343+
return requested, nil
344+
}
345+
276346
type HBAEntry struct {
277347
Type string
278348
Database string

internal/flypg/pg_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,122 @@ func setup(t *testing.T) error {
255255
return nil
256256
}
257257

258+
func TestValidateCompatibility(t *testing.T) {
259+
if err := setup(t); err != nil {
260+
t.Fatal(err)
261+
}
262+
defer cleanup()
263+
264+
pgConf := &PGConfig{
265+
DataDir: pgTestDirectory,
266+
Port: 5433,
267+
ConfigFilePath: pgConfigFilePath,
268+
InternalConfigFilePath: pgInternalConfigFilePath,
269+
UserConfigFilePath: pgUserConfigFilePath,
270+
271+
passwordFilePath: pgPasswordFilePath,
272+
repmgrUsername: "repmgr",
273+
repmgrDatabase: "repgmr",
274+
}
275+
276+
if err := stubPGConfigFile(); err != nil {
277+
t.Fatal(err)
278+
}
279+
280+
store, _ := state.NewStore()
281+
if err := pgConf.initialize(store); err != nil {
282+
t.Fatal(err)
283+
}
284+
t.Run("SharedPreloadLibraries", func(t *testing.T) {
285+
valid := ConfigMap{
286+
"shared_preload_libraries": "repmgr",
287+
}
288+
conf, err := pgConf.validateCompatibility(valid)
289+
if err != nil {
290+
t.Fatal(err)
291+
}
292+
if conf["shared_preload_libraries"].(string) != "'repmgr'" {
293+
t.Fatal("expected preload library string to be wrapped in single quotes")
294+
}
295+
296+
valid = ConfigMap{
297+
"shared_preload_libraries": "'repmgr'",
298+
}
299+
conf, err = pgConf.validateCompatibility(valid)
300+
if err != nil {
301+
t.Fatal(err)
302+
}
303+
if conf["shared_preload_libraries"].(string) != "'repmgr'" {
304+
t.Fatal("expected preload library string to be wrapped in single quotes")
305+
}
306+
307+
valid = ConfigMap{
308+
"shared_preload_libraries": "repmgr,timescaledb",
309+
}
310+
conf, err = pgConf.validateCompatibility(valid)
311+
if err != nil {
312+
t.Fatal(err)
313+
}
314+
if conf["shared_preload_libraries"].(string) != "'repmgr,timescaledb'" {
315+
t.Fatal("expected preload library string to be wrapped in single quotes")
316+
}
317+
318+
valid = ConfigMap{
319+
"shared_preload_libraries": "",
320+
}
321+
if _, err := pgConf.validateCompatibility(valid); err == nil {
322+
t.Fatal("expected validation to fail when empty")
323+
}
324+
325+
valid = ConfigMap{
326+
"shared_preload_libraries": "timescaledb",
327+
}
328+
if _, err := pgConf.validateCompatibility(valid); err == nil {
329+
t.Fatal("expected validation to fail when repmgr is missing")
330+
}
331+
332+
})
333+
334+
t.Run("WalLevel", func(t *testing.T) {
335+
valid := ConfigMap{
336+
"wal_level": "replica",
337+
}
338+
if _, err := pgConf.validateCompatibility(valid); err != nil {
339+
t.Fatal(err)
340+
}
341+
342+
valid = ConfigMap{
343+
"wal_level": "logical",
344+
}
345+
if _, err := pgConf.validateCompatibility(valid); err != nil {
346+
t.Fatal(err)
347+
}
348+
349+
valid = ConfigMap{
350+
"wal_level": "minimal",
351+
"archive_mode": "off",
352+
}
353+
if _, err := pgConf.validateCompatibility(valid); err != nil {
354+
t.Fatal(err)
355+
}
356+
357+
invalid := ConfigMap{
358+
"wal_level": "minimal",
359+
}
360+
361+
if _, err := pgConf.validateCompatibility(invalid); err == nil {
362+
t.Fatal("expected wal_level minimal to fail with archiving enabled")
363+
}
364+
365+
invalid = ConfigMap{
366+
"wal_level": "logical",
367+
}
368+
if _, err := pgConf.validateCompatibility(invalid); err != nil {
369+
t.Fatal(err)
370+
}
371+
})
372+
}
373+
258374
func stubPGConfigFile() error {
259375
file, err := os.Create(pgConfigFilePath)
260376
if err != nil {

0 commit comments

Comments
 (0)