mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-21 11:46:37 +00:00
f5370a5d37
Adds mariadbEngine alongside the PostgreSQL one: a three-stage probe that separates a live-but-unreachable server from an absent one, ALTER USER password rotation on stdin behind a session logging guard, a dynamic SET GLOBAL apply with rollback of the installed file, file-comparison pending-restart against a serving copy, last-known-good restore, service stop and restart, and BACKUP STAGE quiesce on a held session with a bounded lock_wait_timeout. The parameter-file machinery, service actions, quiesce hold bookkeeping and client session are extracted so both engines share them, and a contract suite runs the same cases against both through the interfaces. MariaDB is not registered in the agent's engine table yet, so construction still refuses.
330 lines
12 KiB
Go
330 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
|
)
|
|
|
|
// A probe the test drives directly, in place of whichever binaries the engine's
|
|
// own state function would have run. Each implementation's state function is
|
|
// covered on its own; what these cases exercise is what the engine does with the
|
|
// answer.
|
|
func drivenProbe(state *engineState) *engineProbe {
|
|
return newEngineProbe(0, func(context.Context, int64) (engineState, string) {
|
|
return *state, "driven by the test"
|
|
})
|
|
}
|
|
|
|
// One engine behind the interfaces the heartbeat, the commander and the rollback
|
|
// guard hold, plus the few facts a shared case needs to say what it means.
|
|
type engineHarness struct {
|
|
engine engine
|
|
runner *recordingRunner
|
|
params parameterStore
|
|
service string
|
|
// The state the probe reports, moved by the test rather than by the engine.
|
|
state *engineState
|
|
// Points the next quiesce at a session the test can inspect, and returns the
|
|
// commands those sessions were opened with.
|
|
useSession func(*fakeSession) *[]command
|
|
// A setting this engine's catalog really carries, so a shared case installs
|
|
// something the implementation would genuinely be handed.
|
|
parameter handlers_rds.Parameter
|
|
// What the engine's own include directive globs. Neither the rollback copy
|
|
// nor the serving copy may end in it.
|
|
includeSuffix string
|
|
}
|
|
|
|
func engineHarnesses() []struct {
|
|
name string
|
|
build func(*testing.T) *engineHarness
|
|
} {
|
|
return []struct {
|
|
name string
|
|
build func(*testing.T) *engineHarness
|
|
}{
|
|
{name: enginePostgres, build: buildPostgresHarness},
|
|
{name: engineMariaDB, build: buildMariaDBHarness},
|
|
}
|
|
}
|
|
|
|
func forEachEngine(t *testing.T, run func(*testing.T, *engineHarness)) {
|
|
t.Helper()
|
|
for _, fixture := range engineHarnesses() {
|
|
t.Run(fixture.name, func(t *testing.T) { run(t, fixture.build(t)) })
|
|
}
|
|
}
|
|
|
|
func buildPostgresHarness(t *testing.T) *engineHarness {
|
|
t.Helper()
|
|
runner := &recordingRunner{}
|
|
e := newTestEngine(t, runner.run)
|
|
state := engineServing
|
|
e.probe = drivenProbe(&state)
|
|
|
|
return &engineHarness{
|
|
engine: e, runner: runner, params: e.params, service: e.service, state: &state,
|
|
useSession: func(session *fakeSession) *[]command {
|
|
started := &[]command{}
|
|
e.startSess = func(_ context.Context, c command) (engineSession, error) {
|
|
*started = append(*started, c)
|
|
return session, nil
|
|
}
|
|
return started
|
|
},
|
|
parameter: handlers_rds.Parameter{Name: "work_mem", Value: "4096"},
|
|
includeSuffix: ".conf",
|
|
}
|
|
}
|
|
|
|
func buildMariaDBHarness(t *testing.T) *engineHarness {
|
|
t.Helper()
|
|
runner := &recordingRunner{}
|
|
e := newTestMariaDBEngine(t, runner.run)
|
|
state := engineServing
|
|
e.probe = drivenProbe(&state)
|
|
|
|
return &engineHarness{
|
|
engine: e, runner: runner, params: e.params, service: e.service, state: &state,
|
|
useSession: func(session *fakeSession) *[]command {
|
|
started := &[]command{}
|
|
e.startSess = func(_ context.Context, c command) (engineSession, error) {
|
|
*started = append(*started, c)
|
|
return session, nil
|
|
}
|
|
return started
|
|
},
|
|
parameter: handlers_rds.Parameter{Name: "max_connections", Value: "200"},
|
|
includeSuffix: ".cnf",
|
|
}
|
|
}
|
|
|
|
// The parameters land where rds-init installs them, so the next boot overwrites
|
|
// the file rather than shadowing it with a second copy.
|
|
func TestEngineContract_ApplyParametersInstallsWhereRdsInitDoes(t *testing.T) {
|
|
forEachEngine(t, func(t *testing.T, h *engineHarness) {
|
|
if _, err := h.engine.ApplyParameters(t.Context(), []handlers_rds.Parameter{h.parameter}); err != nil {
|
|
t.Fatalf("ApplyParameters: %v", err)
|
|
}
|
|
|
|
installed, err := os.ReadFile(h.params.installedPath())
|
|
if err != nil {
|
|
t.Fatalf("read the installed parameters: %v", err)
|
|
}
|
|
if !strings.Contains(string(installed), h.parameter.Name+" = '"+h.parameter.Value+"'") {
|
|
t.Errorf("installed parameters = %q, want the resolved value", installed)
|
|
}
|
|
if !strings.HasSuffix(h.params.installedPath(), h.includeSuffix) {
|
|
t.Errorf("the installed set at %s is not a file the engine includes", h.params.installedPath())
|
|
}
|
|
// Comparison material, not a second set of settings: an include glob that
|
|
// read either of these would apply a withdrawn configuration.
|
|
copies := []string{h.params.lastGoodPath()}
|
|
if h.params.serving != "" {
|
|
copies = append(copies, h.params.servingPath())
|
|
}
|
|
for _, path := range copies {
|
|
if strings.HasSuffix(path, h.includeSuffix) {
|
|
t.Errorf("the copy at %s would be included by the engine", path)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// An engine still starting or replaying has not finished adopting the set it is
|
|
// on, so replacing that set underneath it would be installing over a moving
|
|
// target.
|
|
func TestEngineContract_ApplyParametersRefusesWhileTheEngineIsRecovering(t *testing.T) {
|
|
forEachEngine(t, func(t *testing.T, h *engineHarness) {
|
|
*h.state = engineRecovering
|
|
|
|
if _, err := h.engine.ApplyParameters(t.Context(), []handlers_rds.Parameter{h.parameter}); err == nil {
|
|
t.Fatal("ApplyParameters succeeded against an engine that was still coming up")
|
|
}
|
|
if _, err := os.Stat(h.params.installedPath()); !os.IsNotExist(err) {
|
|
t.Errorf("the refused set was installed anyway (stat err = %v)", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// The boot-time half of the parameter safety net: the last set the engine
|
|
// accepted goes back only when the engine is genuinely down, and only once.
|
|
func TestEngineContract_RestoreLastKnownGoodOnlyRollsBackADownEngine(t *testing.T) {
|
|
forEachEngine(t, func(t *testing.T, h *engineHarness) {
|
|
accepted := h.parameter
|
|
if _, err := h.engine.ApplyParameters(t.Context(), []handlers_rds.Parameter{accepted}); err != nil {
|
|
t.Fatalf("ApplyParameters: %v", err)
|
|
}
|
|
if err := h.engine.RecordServingParameters(t.Context()); err != nil {
|
|
t.Fatalf("RecordServingParameters: %v", err)
|
|
}
|
|
if _, err := h.engine.ApplyParameters(t.Context(),
|
|
[]handlers_rds.Parameter{{Name: accepted.Name, Value: accepted.Value + "0"}}); err != nil {
|
|
t.Fatalf("second ApplyParameters: %v", err)
|
|
}
|
|
|
|
// A guard whose deadline expired while an apply brought the engine back must
|
|
// not reverse that repair.
|
|
restored, err := h.engine.RestoreLastKnownGoodParameters(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("RestoreLastKnownGoodParameters against a serving engine: %v", err)
|
|
}
|
|
if restored {
|
|
t.Fatal("rolled the parameters back while the engine was serving")
|
|
}
|
|
|
|
*h.state = engineAbsent
|
|
restored, err = h.engine.RestoreLastKnownGoodParameters(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("RestoreLastKnownGoodParameters: %v", err)
|
|
}
|
|
if !restored {
|
|
t.Fatal("did not roll back to a set that differs from the last accepted one")
|
|
}
|
|
installed, err := os.ReadFile(h.params.installedPath())
|
|
if err != nil {
|
|
t.Fatalf("read the installed parameters: %v", err)
|
|
}
|
|
if !strings.Contains(string(installed), accepted.Name+" = '"+accepted.Value+"'") {
|
|
t.Errorf("installed = %q, want the last accepted set", installed)
|
|
}
|
|
|
|
// Repeating a rollback would only churn a cluster that is failing for
|
|
// another reason.
|
|
restored, err = h.engine.RestoreLastKnownGoodParameters(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("RestoreLastKnownGoodParameters: %v", err)
|
|
}
|
|
if restored {
|
|
t.Error("rolled back again when the installed set already matched")
|
|
}
|
|
})
|
|
}
|
|
|
|
// Through the service manager rather than the engine's own control tool, so the
|
|
// supervisor records the engine as stopped and does not restart it underneath a
|
|
// VM that is going down.
|
|
func TestEngineContract_StopGoesThroughTheServiceManager(t *testing.T) {
|
|
forEachEngine(t, func(t *testing.T, h *engineHarness) {
|
|
if err := h.engine.Stop(t.Context()); err != nil {
|
|
t.Fatalf("Stop: %v", err)
|
|
}
|
|
if len(h.runner.calls) != 1 {
|
|
t.Fatalf("ran %d commands, want the service stop", len(h.runner.calls))
|
|
}
|
|
call := h.runner.calls[0]
|
|
if !slices.Equal(call.Args, []string{h.service, "stop"}) {
|
|
t.Errorf("stop ran %s %v, want the service manager", call.Name, call.Args)
|
|
}
|
|
})
|
|
}
|
|
|
|
// One backup at a time, released by Unquiesce or by its own deadline, whichever
|
|
// comes first. The deadline is what keeps a control plane that dies mid-snapshot
|
|
// from leaving the engine held.
|
|
func TestEngineContract_QuiesceHoldsUntilReleased(t *testing.T) {
|
|
forEachEngine(t, func(t *testing.T, h *engineHarness) {
|
|
session := &fakeSession{}
|
|
started := h.useSession(session)
|
|
|
|
if err := h.engine.Quiesce(t.Context(), "orders-db-pre-upgrade", time.Minute); err != nil {
|
|
t.Fatalf("Quiesce: %v", err)
|
|
}
|
|
if len(*started) != 1 {
|
|
t.Fatalf("started %d sessions, want 1", len(*started))
|
|
}
|
|
if session.closes() != 0 {
|
|
t.Fatal("the session was closed while the backup was meant to be held")
|
|
}
|
|
|
|
err := h.engine.Quiesce(t.Context(), "second", time.Minute)
|
|
if err == nil {
|
|
t.Fatal("a second quiesce was accepted while the first was held")
|
|
}
|
|
if !strings.Contains(err.Error(), "orders-db-pre-upgrade") {
|
|
t.Errorf("error %q does not name the backup already holding the engine", err)
|
|
}
|
|
if len(*started) != 1 {
|
|
t.Errorf("started %d sessions, want the second refused before opening one", len(*started))
|
|
}
|
|
|
|
if err := h.engine.Unquiesce(t.Context()); err != nil {
|
|
t.Fatalf("Unquiesce: %v", err)
|
|
}
|
|
if session.closes() != 1 {
|
|
t.Errorf("closed %d times, want the session ended exactly once", session.closes())
|
|
}
|
|
})
|
|
}
|
|
|
|
// A hold that expired mid-snapshot means the snapshot was not taken against a
|
|
// held checkpoint, so the release reports it rather than succeeding silently.
|
|
func TestEngineContract_QuiesceReleasesItselfOnItsDeadline(t *testing.T) {
|
|
forEachEngine(t, func(t *testing.T, h *engineHarness) {
|
|
session := &fakeSession{}
|
|
h.useSession(session)
|
|
|
|
if err := h.engine.Quiesce(t.Context(), "orders-db-pre-upgrade", 10*time.Millisecond); err != nil {
|
|
t.Fatalf("Quiesce: %v", err)
|
|
}
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for session.closes() == 0 && time.Now().Before(deadline) {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
if session.closes() != 1 {
|
|
t.Fatal("the backup session outlived its deadline")
|
|
}
|
|
|
|
err := h.engine.Unquiesce(t.Context())
|
|
if err == nil {
|
|
t.Fatal("Unquiesce succeeded after the hold expired")
|
|
}
|
|
if !strings.Contains(err.Error(), "expired") {
|
|
t.Errorf("error %q does not say the hold had expired", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// A failed start leaves nothing held, so a retry is not refused by a hold that
|
|
// never took effect.
|
|
func TestEngineContract_QuiesceEndsTheSessionWhenTheBackupWillNotStart(t *testing.T) {
|
|
forEachEngine(t, func(t *testing.T, h *engineHarness) {
|
|
session := &fakeSession{execErr: errors.New("the engine refused the backup")}
|
|
h.useSession(session)
|
|
|
|
if err := h.engine.Quiesce(t.Context(), "orders-db-pre-upgrade", time.Minute); err == nil {
|
|
t.Fatal("Quiesce succeeded against an engine that refused the backup")
|
|
}
|
|
if session.closes() != 1 {
|
|
t.Errorf("closed %d times, want the failed session ended", session.closes())
|
|
}
|
|
if err := h.engine.Unquiesce(t.Context()); err == nil {
|
|
t.Error("a failed quiesce left a hold behind")
|
|
}
|
|
})
|
|
}
|
|
|
|
// Every command the control plane can issue is served by both implementations,
|
|
// so a directive cannot be answered as unsupported on one engine and honoured on
|
|
// the other.
|
|
func TestEngineContract_ServesEveryRegisteredCommand(t *testing.T) {
|
|
forEachEngine(t, func(t *testing.T, h *engineHarness) {
|
|
registry := newCommandRegistry(h.engine, &fakeStorage{})
|
|
for _, name := range []string{
|
|
handlers_rds.CommandSetPassword, handlers_rds.CommandApplyParams, handlers_rds.CommandStopEngine,
|
|
handlers_rds.CommandGrowFilesystem, handlers_rds.CommandQuiesce, handlers_rds.CommandUnquiesce,
|
|
} {
|
|
if _, ok := registry[name]; !ok {
|
|
t.Errorf("command %q has no handler", name)
|
|
}
|
|
}
|
|
})
|
|
}
|