mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 11:21:49 +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.
380 lines
14 KiB
Go
380 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
|
)
|
|
|
|
// The engine is reached over its unix socket under peer authentication, so the
|
|
// agent drops to the postgres OS user rather than holding a password of its own.
|
|
type postgresEngine struct {
|
|
quiesceState
|
|
|
|
// The control plane's own metadata for this engine, resolved once at
|
|
// startup: the live password apply runs as the cluster superuser, so it
|
|
// re-checks the role name against the same reserved set the API validates.
|
|
meta handlers_rds.Engine
|
|
run commandRunner
|
|
startSess sessionRunner
|
|
psql string
|
|
rcService string
|
|
service string
|
|
pgData string
|
|
socketDir string
|
|
osUser string
|
|
probe *engineProbe
|
|
// Set from the bootstrap config, on a different goroutine than the commands
|
|
// that read it.
|
|
port atomic.Int64
|
|
|
|
params parameterStore
|
|
// Serializes parameter installs, serving snapshots and rollback restores so
|
|
// none can copy or replace an intermediate configuration.
|
|
paramMu sync.Mutex
|
|
repairTimeout time.Duration
|
|
repairPoll time.Duration
|
|
}
|
|
|
|
var _ engine = (*postgresEngine)(nil)
|
|
|
|
// pg_isready is what gives PostgreSQL three states for free: exit 0 is serving,
|
|
// exit 1 is a postmaster up and rejecting connections, and anything else is an
|
|
// engine that is not there at all. Resolved on PATH, where the client package
|
|
// puts it.
|
|
const postgresProbeBinary = "pg_isready"
|
|
|
|
func newPostgresProbe(cfg config, run probeRunner) *engineProbe {
|
|
return newEngineProbe(cfg.EnginePort, postgresProbeState(cfg.EngineHost, run))
|
|
}
|
|
|
|
func postgresProbeState(host string, run probeRunner) probeStateFn {
|
|
return func(ctx context.Context, port int64) (engineState, string) {
|
|
portArg := strconv.FormatInt(port, 10)
|
|
code, err := run(ctx, postgresProbeBinary, "-h", host, "-p", portArg, "-q")
|
|
switch {
|
|
case err != nil:
|
|
// A missing binary or broken image. Reporting healthy on the strength of
|
|
// nothing would hide it, so this reads as absent like an engine that did
|
|
// not answer.
|
|
return engineAbsent, fmt.Sprintf("engine probe could not run: %v", err)
|
|
case code == 0:
|
|
return engineServing, ""
|
|
case code == 1:
|
|
return engineRecovering, "engine is rejecting connections (startup or recovery)"
|
|
default:
|
|
return engineAbsent, fmt.Sprintf("engine did not respond on %s:%s", host, portArg)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Resolved once, under the name the control plane knows this implementation by.
|
|
// A lookup failure is a mismatch between the agent and the control plane it was
|
|
// built against, so it fails at startup rather than at the first rotation.
|
|
var postgresEngineMeta = mustLookupEngine(enginePostgres)
|
|
|
|
func mustLookupEngine(name string) handlers_rds.Engine {
|
|
engine, err := handlers_rds.LookupEngine(name)
|
|
if err != nil {
|
|
panic("rds-agent: " + err.Error())
|
|
}
|
|
return engine
|
|
}
|
|
|
|
// The include the resolved parameter set is rendered to, and the copy of the
|
|
// last one the engine accepted. Both live beside the data rather than in /etc,
|
|
// matching rds-init: a class change boots a fresh root volume, which would
|
|
// otherwise revert them.
|
|
const (
|
|
postgresParametersFile = "10-rds-parameters.conf"
|
|
// Deliberately not a .conf name: include_dir globs *.conf, so the rollback
|
|
// copy must not be read as a second set of settings.
|
|
postgresLastGoodFile = "10-rds-parameters.last-good"
|
|
)
|
|
|
|
func newPostgresEngine(cfg config, run commandRunner, startSess sessionRunner, probe *engineProbe) *postgresEngine {
|
|
e := &postgresEngine{
|
|
meta: postgresEngineMeta,
|
|
run: run,
|
|
startSess: startSess,
|
|
psql: filepath.Join(cfg.EngineBinDir, "psql"),
|
|
rcService: cfg.RCService,
|
|
service: cfg.EngineService,
|
|
pgData: cfg.EngineDataDir,
|
|
socketDir: cfg.SocketDir,
|
|
osUser: cfg.EngineUser,
|
|
probe: probe,
|
|
params: parameterStore{
|
|
dir: filepath.Join(cfg.EngineDataDir, "conf.d"),
|
|
installed: postgresParametersFile,
|
|
lastGood: postgresLastGoodFile,
|
|
osUser: cfg.EngineUser,
|
|
},
|
|
repairTimeout: parameterRepairTimeout,
|
|
repairPoll: parameterRepairPoll,
|
|
}
|
|
e.port.Store(int64(cfg.EnginePort))
|
|
return e
|
|
}
|
|
|
|
func (e *postgresEngine) setPort(port int) {
|
|
e.port.Store(int64(port))
|
|
}
|
|
|
|
// The role name and the password ride the environment and are re-quoted by
|
|
// psql, so neither reaches a shell word or an argv another process can read.
|
|
func (e *postgresEngine) SetPassword(ctx context.Context, username, password string) error {
|
|
if username == "" || password == "" {
|
|
return fmt.Errorf("set-password requires both %s and %s",
|
|
handlers_rds.CommandParamMasterUsername, handlers_rds.CommandParamMasterUserPassword)
|
|
}
|
|
// The statement below runs as the cluster superuser over the socket under
|
|
// peer auth, so a reserved name in the command payload would hand the
|
|
// customer the bootstrap superuser rather than rotate their own role.
|
|
if err := e.meta.ValidateUsernameNotReserved(username); err != nil {
|
|
return fmt.Errorf("refusing to set the password of a role the engine reserves: %w", err)
|
|
}
|
|
// psql interpolates the password into the ALTER ROLE before the server sees
|
|
// it, and these three are what would write it to the log — the last on any
|
|
// failure at its own default. SUSET, so the parameter group cannot win.
|
|
const sql = `SET log_statement = 'none';
|
|
SET log_min_duration_statement = -1;
|
|
SET log_min_error_statement = 'panic';
|
|
\getenv master RDS_MASTER_USERNAME
|
|
\getenv password RDS_MASTER_PASSWORD
|
|
ALTER ROLE :"master" WITH LOGIN PASSWORD :'password';
|
|
`
|
|
_, err := e.psqlRun(ctx, sql,
|
|
"RDS_MASTER_USERNAME="+username,
|
|
"RDS_MASTER_PASSWORD="+password,
|
|
)
|
|
if err != nil {
|
|
// psql echoes the failing statement, which here would carry the new
|
|
// password back to the control plane and into the event ring.
|
|
return fmt.Errorf("apply the master password: %s", redact(err.Error(), password))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Installs the resolved set, validates it with the engine's own config parser
|
|
// before the engine ever adopts it, and reloads. A value the engine refuses is
|
|
// rolled back here rather than left on the data volume, where it would survive
|
|
// every VM replace and turn the next restart into a boot loop.
|
|
func (e *postgresEngine) ApplyParameters(ctx context.Context, params []handlers_rds.Parameter) ([]string, error) {
|
|
e.paramMu.Lock()
|
|
defer e.paramMu.Unlock()
|
|
|
|
initialState, _ := e.probe.state(ctx)
|
|
if initialState == engineRecovering {
|
|
return nil, errors.New("apply parameters while the engine is still starting or recovering")
|
|
}
|
|
if initialState == engineServing {
|
|
// A command can arrive before the first heartbeat. Seed the configuration
|
|
// currently being served before replacing its file in that window.
|
|
if err := e.recordServingParametersLocked(ctx); err != nil {
|
|
return nil, fmt.Errorf("record the parameters serving before the apply: %w", err)
|
|
}
|
|
}
|
|
|
|
// The check has to run against the file in place, because the engine parses
|
|
// the datadir's own include_dir. The window that leaves is closed from both
|
|
// ends: the rollback below, and the last-known-good restore the agent runs at
|
|
// boot when the engine does not come up.
|
|
restore, err := e.params.install(params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := e.checkConfig(ctx); err != nil {
|
|
return nil, errors.Join(fmt.Errorf("the engine rejected the parameter set: %w", err), restore())
|
|
}
|
|
|
|
state, _ := e.probe.state(ctx)
|
|
switch state {
|
|
case engineAbsent:
|
|
return e.restartOnRepairSetLocked(ctx)
|
|
case engineRecovering:
|
|
return nil, errors.Join(errors.New("the engine entered startup or recovery during the parameter apply"), restore())
|
|
}
|
|
|
|
if _, err := e.psqlRun(ctx, "SELECT pg_reload_conf();\n"); err != nil {
|
|
// The engine may have gone down after the first probe. In that case start
|
|
// it on the new, parser-checked repair set rather than restoring the set
|
|
// that already left it unable to serve.
|
|
if state, _ := e.probe.state(ctx); state == engineAbsent {
|
|
return e.restartOnRepairSetLocked(ctx)
|
|
}
|
|
return nil, errors.Join(fmt.Errorf("reload the engine configuration: %w", err), restore())
|
|
}
|
|
return e.pendingRestartParameters(ctx)
|
|
}
|
|
|
|
func (e *postgresEngine) restartOnRepairSetLocked(ctx context.Context) ([]string, error) {
|
|
return awaitRepairedEngine(ctx, e.probe, e.Restart, e.pendingRestartParameters, e.repairTimeout, e.repairPoll)
|
|
}
|
|
|
|
// The postmaster's own answer: a static setting it has stored but not adopted is
|
|
// what it reports here, so nothing in the guest has to classify one.
|
|
func (e *postgresEngine) pendingRestartParameters(ctx context.Context) ([]string, error) {
|
|
out, err := e.psqlRun(ctx, "SELECT name FROM pg_settings WHERE pending_restart ORDER BY name;\n")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read the settings pending a restart: %w", err)
|
|
}
|
|
var pending []string
|
|
for line := range strings.SplitSeq(out, "\n") {
|
|
if name := strings.TrimSpace(line); name != "" {
|
|
pending = append(pending, name)
|
|
}
|
|
}
|
|
return pending, nil
|
|
}
|
|
|
|
// Snapshots the include only when the running postmaster has no settings still
|
|
// pending a restart. The parameter mutex keeps an apply from replacing the file
|
|
// between that check and the copy.
|
|
func (e *postgresEngine) RecordServingParameters(ctx context.Context) error {
|
|
e.paramMu.Lock()
|
|
defer e.paramMu.Unlock()
|
|
return e.recordServingParametersLocked(ctx)
|
|
}
|
|
|
|
func (e *postgresEngine) recordServingParametersLocked(ctx context.Context) error {
|
|
return recordLastGood(ctx, e.params, e.pendingRestartParameters)
|
|
}
|
|
|
|
// Puts the last set the engine accepted back in place, for a restart that failed
|
|
// after a parameter change.
|
|
func (e *postgresEngine) RestoreLastKnownGoodParameters(ctx context.Context) (bool, error) {
|
|
e.paramMu.Lock()
|
|
defer e.paramMu.Unlock()
|
|
return restoreLastGood(ctx, e.params, e.probe)
|
|
}
|
|
|
|
// The engine's own parser, run offline against the datadir. Reading one setting
|
|
// back is enough: postgres parses postgresql.conf and every include first, and
|
|
// exits non-zero naming the file and line of an unknown parameter or a value
|
|
// outside its range.
|
|
func (e *postgresEngine) checkConfig(ctx context.Context) error {
|
|
if _, err := e.run(ctx, command{
|
|
Name: filepath.Join(filepath.Dir(e.psql), "postgres"),
|
|
Args: []string{"-D", e.pgData, "-C", "shared_buffers"},
|
|
Env: []string{"PATH=" + defaultGuestPath},
|
|
User: e.osUser,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *postgresEngine) Stop(ctx context.Context) error {
|
|
return serviceAction(ctx, e.run, e.rcService, e.service, "stop")
|
|
}
|
|
|
|
// Only the parameter rollback calls this: a restart the control plane wants goes
|
|
// through RebootDBInstance, which cycles the VM.
|
|
func (e *postgresEngine) Restart(ctx context.Context) error {
|
|
return serviceAction(ctx, e.run, e.rcService, e.service, "restart")
|
|
}
|
|
|
|
// Feeds sql on stdin rather than as an argument, so a statement is never
|
|
// visible in the process table.
|
|
func (e *postgresEngine) psqlRun(ctx context.Context, sql string, env ...string) (string, error) {
|
|
return e.run(ctx, command{
|
|
Name: e.psql,
|
|
Args: e.psqlArgs(),
|
|
Env: append([]string{"PATH=" + defaultGuestPath}, env...),
|
|
Stdin: sql,
|
|
User: e.osUser,
|
|
})
|
|
}
|
|
|
|
// Reading the script from stdin is what lets one invocation serve both a
|
|
// one-shot run and a session held open across several statements.
|
|
func (e *postgresEngine) psqlArgs() []string {
|
|
return []string{
|
|
"--no-psqlrc", "--quiet", "--no-align", "--tuples-only",
|
|
"-v", "ON_ERROR_STOP=1",
|
|
"-h", e.socketDir,
|
|
"-p", strconv.FormatInt(e.port.Load(), 10),
|
|
"-U", e.osUser,
|
|
"-d", "postgres",
|
|
"-f", "-",
|
|
}
|
|
}
|
|
|
|
// Puts the engine into backup mode: the datadir is checkpointed and the engine
|
|
// stops writing over the pages a snapshot is about to read. The hold is released
|
|
// by Unquiesce, or by its own deadline, whichever comes first.
|
|
func (e *postgresEngine) Quiesce(ctx context.Context, label string, hold time.Duration) error {
|
|
if err := validateQuiesceRequest(label, hold); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Held across the whole start, so a second quiesce waits and then finds the
|
|
// first one's hold rather than opening a concurrent backup alongside it.
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
if e.held != nil {
|
|
return fmt.Errorf("the engine is already quiesced for backup %s", e.held.label)
|
|
}
|
|
|
|
// Deliberately not the command's context: the session has to outlive the call
|
|
// that started it, and its own deadline is what bounds it instead.
|
|
session, err := e.startSess(context.WithoutCancel(ctx), command{
|
|
Name: e.psql,
|
|
Args: e.psqlArgs(),
|
|
Env: []string{"PATH=" + defaultGuestPath, "RDS_BACKUP_LABEL=" + label},
|
|
User: e.osUser,
|
|
SentinelStatement: `\echo ` + sessionSentinel + "\n",
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("open a backup session: %w", err)
|
|
}
|
|
|
|
// fast forces an immediate checkpoint rather than spreading it over the
|
|
// checkpoint interval, which would hold the snapshot open for minutes.
|
|
const sql = `\getenv label RDS_BACKUP_LABEL
|
|
SELECT pg_backup_start(:'label', fast => true);
|
|
`
|
|
if err := session.Exec(ctx, sql); err != nil {
|
|
if closeErr := session.Close(); closeErr != nil {
|
|
slog.Warn("rds-agent: closing a failed backup session", "err", closeErr)
|
|
}
|
|
return fmt.Errorf("put the engine into backup mode: %w", err)
|
|
}
|
|
|
|
e.beginHoldLocked(label, session, hold)
|
|
return nil
|
|
}
|
|
|
|
// Ends the backup cleanly. A missing hold is an error rather than a silent
|
|
// success: it means the deadline fired first, so the snapshot the control plane
|
|
// just took was not taken against a held checkpoint.
|
|
func (e *postgresEngine) Unquiesce(ctx context.Context) error {
|
|
held := e.takeHold()
|
|
if held == nil {
|
|
return errors.New("the engine is not quiesced; the backup hold had already expired")
|
|
}
|
|
|
|
// The stop has to run on the session that started the backup, and the session
|
|
// is closed either way — the engine aborts an unstopped backup with it.
|
|
execErr := held.session.Exec(ctx, "SELECT pg_backup_stop();\n")
|
|
closeErr := held.session.Close()
|
|
if execErr != nil {
|
|
return fmt.Errorf("take the engine out of backup mode: %w", errors.Join(execErr, closeErr))
|
|
}
|
|
if closeErr != nil {
|
|
return fmt.Errorf("close the backup session: %w", closeErr)
|
|
}
|
|
slog.Info("rds-agent: engine released from backup mode", "label", held.label)
|
|
return nil
|
|
}
|