mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
403 lines
14 KiB
Go
403 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"os/user"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
|
)
|
|
|
|
// Stands in for the engine in the registry tests, where what matters is that a
|
|
// command reaches the right method with the right arguments.
|
|
type fakeEngine struct {
|
|
username string
|
|
password string
|
|
params []handlers_rds.Parameter
|
|
pending []string
|
|
stopped bool
|
|
label string
|
|
hold time.Duration
|
|
released bool
|
|
err error
|
|
}
|
|
|
|
var _ engineOps = (*fakeEngine)(nil)
|
|
|
|
func (f *fakeEngine) SetPassword(_ context.Context, username, password string) error {
|
|
f.username, f.password = username, password
|
|
return f.err
|
|
}
|
|
|
|
func (f *fakeEngine) ApplyParameters(_ context.Context, params []handlers_rds.Parameter) ([]string, error) {
|
|
f.params = params
|
|
return f.pending, f.err
|
|
}
|
|
|
|
func (f *fakeEngine) Stop(context.Context) error {
|
|
f.stopped = true
|
|
return f.err
|
|
}
|
|
|
|
func (f *fakeEngine) Quiesce(_ context.Context, label string, hold time.Duration) error {
|
|
f.label, f.hold = label, hold
|
|
return f.err
|
|
}
|
|
|
|
func (f *fakeEngine) Unquiesce(context.Context) error {
|
|
f.released = true
|
|
return f.err
|
|
}
|
|
|
|
func TestCommandRegistry_SetPasswordCarriesBothParameters(t *testing.T) {
|
|
engine := &fakeEngine{}
|
|
reply := newCommander(nil, newCommandRegistry(engine, &fakeStorage{}), 0).execute(context.Background(), handlers_rds.Command{
|
|
CommandID: "cmd-1",
|
|
Type: handlers_rds.CommandSetPassword,
|
|
Parameters: []handlers_rds.Parameter{
|
|
{Name: handlers_rds.CommandParamMasterUsername, Value: "mulgamaster"},
|
|
{Name: handlers_rds.CommandParamMasterUserPassword, Value: "n3w-pw"},
|
|
},
|
|
})
|
|
|
|
if reply.Status != handlers_rds.CommandStatusSucceeded {
|
|
t.Fatalf("reply = %+v, want succeeded", reply)
|
|
}
|
|
if engine.username != "mulgamaster" || engine.password != "n3w-pw" {
|
|
t.Errorf("engine got %q/%q, want mulgamaster/n3w-pw", engine.username, engine.password)
|
|
}
|
|
// The reply is recorded by the control plane; the new password must not be
|
|
// in it.
|
|
if strings.Contains(reply.Message, "n3w-pw") {
|
|
t.Errorf("reply message %q carries the new password", reply.Message)
|
|
}
|
|
}
|
|
|
|
// CommandReply has no structured payload, so the settings still awaiting a
|
|
// restart come back as the message the issuer parses.
|
|
func TestCommandRegistry_ApplyParamsReportsPendingRestart(t *testing.T) {
|
|
engine := &fakeEngine{pending: []string{"shared_buffers", "max_connections"}}
|
|
reply := newCommander(nil, newCommandRegistry(engine, &fakeStorage{}), 0).execute(context.Background(), handlers_rds.Command{
|
|
CommandID: "cmd-2",
|
|
Type: handlers_rds.CommandApplyParams,
|
|
Parameters: []handlers_rds.Parameter{{Name: "shared_buffers", Value: "256MB"}},
|
|
})
|
|
|
|
if reply.Status != handlers_rds.CommandStatusSucceeded {
|
|
t.Fatalf("reply = %+v, want succeeded", reply)
|
|
}
|
|
if reply.Message != "shared_buffers,max_connections" {
|
|
t.Errorf("reply message = %q, want the pending-restart list", reply.Message)
|
|
}
|
|
if len(engine.params) != 1 || engine.params[0].Name != "shared_buffers" {
|
|
t.Errorf("engine got %+v, want the command's parameters", engine.params)
|
|
}
|
|
}
|
|
|
|
func TestCommandRegistry_StopEngineReachesTheEngine(t *testing.T) {
|
|
engine := &fakeEngine{}
|
|
reply := newCommander(nil, newCommandRegistry(engine, &fakeStorage{}), 0).execute(context.Background(),
|
|
handlers_rds.Command{CommandID: "cmd-3", Type: handlers_rds.CommandStopEngine})
|
|
|
|
if reply.Status != handlers_rds.CommandStatusSucceeded {
|
|
t.Fatalf("reply = %+v, want succeeded", reply)
|
|
}
|
|
if !engine.stopped {
|
|
t.Error("stop-engine did not reach the engine")
|
|
}
|
|
}
|
|
|
|
func TestCommandRegistry_FailureRepliesFailed(t *testing.T) {
|
|
engine := &fakeEngine{err: errors.New("the engine did not stop")}
|
|
reply := newCommander(nil, newCommandRegistry(engine, &fakeStorage{}), 0).execute(context.Background(),
|
|
handlers_rds.Command{CommandID: "cmd-4", Type: handlers_rds.CommandStopEngine})
|
|
|
|
if reply.Status != handlers_rds.CommandStatusFailed {
|
|
t.Fatalf("reply = %+v, want failed", reply)
|
|
}
|
|
if !strings.Contains(reply.Message, "the engine did not stop") {
|
|
t.Errorf("reply message = %q, want the engine's error", reply.Message)
|
|
}
|
|
}
|
|
|
|
// Records what a postgresEngine would have run, so the SQL and the argv are
|
|
// asserted without a live cluster.
|
|
type recordingRunner struct {
|
|
calls []command
|
|
out string
|
|
err error
|
|
}
|
|
|
|
func (r *recordingRunner) run(_ context.Context, c command) (string, error) {
|
|
r.calls = append(r.calls, c)
|
|
return r.out, r.err
|
|
}
|
|
|
|
func newTestEngine(t *testing.T, run commandRunner) *postgresEngine {
|
|
t.Helper()
|
|
cfg := testLoadConfig(t, enginePostgres)
|
|
cfg.EngineDataDir = t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(cfg.EngineDataDir, "conf.d"), 0o700); err != nil {
|
|
t.Fatalf("create conf.d: %v", err)
|
|
}
|
|
// The guest's postgres user does not exist here, and the chown of the
|
|
// installed parameter file has to resolve to something real.
|
|
current, err := user.Current()
|
|
if err != nil {
|
|
t.Fatalf("resolve the current user: %v", err)
|
|
}
|
|
cfg.EngineUser = current.Username
|
|
return newPostgresEngine(cfg, run, nil, newPostgresProbe(cfg, staticProbe(0)))
|
|
}
|
|
|
|
// The password reaches psql through the environment and is re-quoted there,
|
|
// so it is never a shell word or an argv another process can read.
|
|
func TestPostgresEngine_SetPasswordKeepsTheSecretOutOfArgv(t *testing.T) {
|
|
runner := &recordingRunner{}
|
|
engine := newTestEngine(t, runner.run)
|
|
|
|
if err := engine.SetPassword(context.Background(), "mulgamaster", "n3w-pw"); err != nil {
|
|
t.Fatalf("SetPassword: %v", err)
|
|
}
|
|
if len(runner.calls) != 1 {
|
|
t.Fatalf("ran %d commands, want 1", len(runner.calls))
|
|
}
|
|
call := runner.calls[0]
|
|
for _, arg := range call.Args {
|
|
if strings.Contains(arg, "n3w-pw") {
|
|
t.Fatalf("argv %v carries the password", call.Args)
|
|
}
|
|
}
|
|
if strings.Contains(call.Stdin, "n3w-pw") {
|
|
t.Errorf("SQL %q carries the password literally, want it read from the environment", call.Stdin)
|
|
}
|
|
if !slices.Contains(call.Env, "RDS_MASTER_PASSWORD=n3w-pw") {
|
|
t.Errorf("env %v does not carry the password", call.Env)
|
|
}
|
|
if !strings.Contains(call.Stdin, "ALTER ROLE") {
|
|
t.Errorf("SQL %q does not alter the role", call.Stdin)
|
|
}
|
|
}
|
|
|
|
// psql interpolates the password into the ALTER ROLE before the server sees it,
|
|
// so a parameter group that turns statement logging on would write the rotated
|
|
// password into a postmaster.log every snapshot then carries.
|
|
func TestPostgresEngine_SetPasswordSilencesStatementLogging(t *testing.T) {
|
|
runner := &recordingRunner{}
|
|
engine := newTestEngine(t, runner.run)
|
|
|
|
if err := engine.SetPassword(context.Background(), "mulgamaster", "n3w-pw"); err != nil {
|
|
t.Fatalf("SetPassword: %v", err)
|
|
}
|
|
if len(runner.calls) != 1 {
|
|
t.Fatalf("ran %d commands, want 1", len(runner.calls))
|
|
}
|
|
sql := runner.calls[0].Stdin
|
|
alter := strings.Index(sql, "ALTER ROLE")
|
|
if alter < 0 {
|
|
t.Fatalf("SQL %q does not alter the role", sql)
|
|
}
|
|
for _, guard := range []string{
|
|
"SET log_statement = 'none';",
|
|
"SET log_min_duration_statement = -1;",
|
|
"SET log_min_error_statement = 'panic';",
|
|
} {
|
|
at := strings.Index(sql, guard)
|
|
if at < 0 {
|
|
t.Errorf("SQL %q is missing %q", sql, guard)
|
|
continue
|
|
}
|
|
if at > alter {
|
|
t.Errorf("SQL %q sets %q only after the ALTER ROLE has already been logged", sql, guard)
|
|
}
|
|
}
|
|
}
|
|
|
|
// psql echoes the failing statement, which here would carry the new password
|
|
// back into a reply the control plane records.
|
|
func TestPostgresEngine_SetPasswordRedactsTheSecretFromAFailure(t *testing.T) {
|
|
runner := &recordingRunner{err: errors.New(`ERROR: syntax error at or near "n3w-pw"`)}
|
|
engine := newTestEngine(t, runner.run)
|
|
|
|
err := engine.SetPassword(context.Background(), "mulgamaster", "n3w-pw")
|
|
if err == nil {
|
|
t.Fatal("SetPassword succeeded against a failing psql")
|
|
}
|
|
if strings.Contains(err.Error(), "n3w-pw") {
|
|
t.Errorf("error %q leaks the password", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "[REDACTED]") {
|
|
t.Errorf("error %q does not mark the redaction", err)
|
|
}
|
|
}
|
|
|
|
func TestPostgresEngine_SetPasswordRejectsAnIncompleteCommand(t *testing.T) {
|
|
runner := &recordingRunner{}
|
|
engine := newTestEngine(t, runner.run)
|
|
|
|
if err := engine.SetPassword(context.Background(), "mulgamaster", ""); err == nil {
|
|
t.Error("SetPassword accepted an empty password")
|
|
}
|
|
if len(runner.calls) != 0 {
|
|
t.Errorf("ran %d commands for an incomplete request, want 0", len(runner.calls))
|
|
}
|
|
}
|
|
|
|
// The apply runs as the cluster superuser under peer auth, so a reserved
|
|
// name arriving in a command payload would rotate the bootstrap superuser's
|
|
// password rather than the customer's own role.
|
|
func TestPostgresEngine_SetPasswordRefusesReservedRoles(t *testing.T) {
|
|
for _, username := range []string{"postgres", "rds_superuser", "rdsadmin", "pg_toast_owner", "PostGres"} {
|
|
t.Run(username, func(t *testing.T) {
|
|
runner := &recordingRunner{}
|
|
engine := newTestEngine(t, runner.run)
|
|
|
|
err := engine.SetPassword(context.Background(), username, "n3w-pw")
|
|
if err == nil {
|
|
t.Fatalf("SetPassword accepted the reserved role %q", username)
|
|
}
|
|
if len(runner.calls) != 0 {
|
|
t.Errorf("ran %d commands for a reserved role, want 0", len(runner.calls))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The parameters land where rds-init installs them, so the next boot overwrites
|
|
// the file rather than shadowing it with a second copy.
|
|
func TestPostgresEngine_ApplyParametersInstallsAndReloads(t *testing.T) {
|
|
runner := &recordingRunner{out: "shared_buffers\nmax_connections\n"}
|
|
engine := newTestEngine(t, runner.run)
|
|
|
|
pending, err := engine.ApplyParameters(context.Background(),
|
|
[]handlers_rds.Parameter{{Name: "shared_buffers", Value: "256MB"}})
|
|
if err != nil {
|
|
t.Fatalf("ApplyParameters: %v", err)
|
|
}
|
|
|
|
installed, err := os.ReadFile(filepath.Join(engine.pgData, "conf.d", "10-rds-parameters.conf"))
|
|
if err != nil {
|
|
t.Fatalf("read the installed parameters: %v", err)
|
|
}
|
|
if !strings.Contains(string(installed), "shared_buffers = '256MB'") {
|
|
t.Errorf("installed parameters = %q, want the resolved value", installed)
|
|
}
|
|
if len(runner.calls) != 3 {
|
|
t.Fatalf("ran %d commands, want a config check, a reload and a pending-restart read", len(runner.calls))
|
|
}
|
|
// The check runs before the reload, so a value the engine would refuse never
|
|
// reaches a running cluster.
|
|
if !slices.Contains(runner.calls[0].Args, "-C") {
|
|
t.Errorf("first command = %v, want the offline config check", runner.calls[0].Args)
|
|
}
|
|
if !strings.Contains(runner.calls[1].Stdin, "pg_reload_conf") {
|
|
t.Errorf("second statement = %q, want the reload", runner.calls[1].Stdin)
|
|
}
|
|
if !strings.Contains(runner.calls[2].Stdin, "pending_restart") {
|
|
t.Errorf("third statement = %q, want the pending-restart read", runner.calls[2].Stdin)
|
|
}
|
|
if len(pending) != 2 || pending[0] != "shared_buffers" || pending[1] != "max_connections" {
|
|
t.Errorf("pending = %v, want both settings the engine reported", pending)
|
|
}
|
|
}
|
|
|
|
// A reload that failed leaves the engine on its old values, so reporting the
|
|
// apply as successful would tell the customer a change took effect that did not.
|
|
func TestPostgresEngine_ApplyParametersSurfacesAFailedReload(t *testing.T) {
|
|
runner := &reloadFailingRunner{reloadErr: errors.New("reload was rejected")}
|
|
engine := newTestEngine(t, runner.run)
|
|
|
|
if _, err := engine.ApplyParameters(context.Background(),
|
|
[]handlers_rds.Parameter{{Name: "work_mem", Value: "8MB"}}); err == nil {
|
|
t.Fatal("ApplyParameters succeeded against a failing reload")
|
|
}
|
|
}
|
|
|
|
type reloadFailingRunner struct {
|
|
recordingRunner
|
|
|
|
reloadErr error
|
|
}
|
|
|
|
func (r *reloadFailingRunner) run(ctx context.Context, c command) (string, error) {
|
|
if strings.Contains(c.Stdin, "pg_reload_conf") {
|
|
r.calls = append(r.calls, c)
|
|
return "", r.reloadErr
|
|
}
|
|
return r.recordingRunner.run(ctx, c)
|
|
}
|
|
|
|
// A failed instance cannot reload through psql. Its corrected, parser-checked
|
|
// include must start the service and remain installed as the serving set.
|
|
func TestPostgresEngine_ApplyParametersRestartsOnARepairSetWhileEngineIsDown(t *testing.T) {
|
|
runner := &recordingRunner{}
|
|
engine := newTestEngine(t, runner.run)
|
|
codes := []int{2, 2, 0}
|
|
probeCall := 0
|
|
cfg := testProbeConfig()
|
|
engine.probe = newPostgresProbe(cfg, func(context.Context, string, ...string) (int, string, error) {
|
|
code := codes[min(probeCall, len(codes)-1)]
|
|
probeCall++
|
|
return code, "", nil
|
|
})
|
|
engine.repairTimeout = 100 * time.Millisecond
|
|
engine.repairPoll = time.Millisecond
|
|
params := []handlers_rds.Parameter{
|
|
{Name: "shared_buffers", Value: "32768"},
|
|
{Name: "work_mem", Value: "8192"},
|
|
}
|
|
|
|
pending, err := engine.ApplyParameters(t.Context(), params)
|
|
if err != nil {
|
|
t.Fatalf("ApplyParameters: %v", err)
|
|
}
|
|
if len(pending) != 0 {
|
|
t.Errorf("pending = %v, want the restarted engine's actual pending set", pending)
|
|
}
|
|
if len(runner.calls) != 3 {
|
|
t.Fatalf("calls = %+v, want config check, service restart and pending read", runner.calls)
|
|
}
|
|
if !slices.Contains(runner.calls[0].Args, "-C") {
|
|
t.Errorf("first call = %+v, want the offline config check", runner.calls[0])
|
|
}
|
|
if !slices.Equal(runner.calls[1].Args, []string{"postgresql", "restart"}) {
|
|
t.Errorf("second call args = %v, want the service restart", runner.calls[1].Args)
|
|
}
|
|
if !strings.Contains(runner.calls[2].Stdin, "pending_restart") {
|
|
t.Errorf("third call = %+v, want the pending-restart read", runner.calls[2])
|
|
}
|
|
installed, err := os.ReadFile(engine.params.installedPath())
|
|
if err != nil {
|
|
t.Fatalf("read installed parameters: %v", err)
|
|
}
|
|
if !strings.Contains(string(installed), "work_mem = '8192'") {
|
|
t.Errorf("installed parameters = %q, want the repair set retained", installed)
|
|
}
|
|
}
|
|
|
|
func TestPostgresEngine_ApplyParametersKeepsRepairSetWhenRestartTimesOut(t *testing.T) {
|
|
runner := &recordingRunner{}
|
|
engine := newTestEngine(t, runner.run)
|
|
engine.probe = stubProbe(t, 2, nil)
|
|
engine.repairTimeout = 10 * time.Millisecond
|
|
engine.repairPoll = time.Millisecond
|
|
|
|
_, err := engine.ApplyParameters(t.Context(), []handlers_rds.Parameter{{Name: "work_mem", Value: "8192"}})
|
|
if err == nil || !strings.Contains(err.Error(), "wait for the engine") {
|
|
t.Fatalf("ApplyParameters error = %v, want the repair wait failure", err)
|
|
}
|
|
installed, readErr := os.ReadFile(engine.params.installedPath())
|
|
if readErr != nil {
|
|
t.Fatalf("read installed parameters: %v", readErr)
|
|
}
|
|
if !strings.Contains(string(installed), "work_mem = '8192'") {
|
|
t.Errorf("installed parameters = %q, want the checked repair set retained", installed)
|
|
}
|
|
}
|