mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
341 lines
13 KiB
Go
341 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"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
|
|
parameterManager
|
|
|
|
// 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
|
|
}
|
|
|
|
var _ engine = (*postgresEngine)(nil)
|
|
|
|
// pg_isready gives PostgreSQL three states for free: exit 0 is serving, exit 1
|
|
// is a postmaster up and rejecting connections, anything else is an engine that
|
|
// is not there. 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 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 and would 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 {
|
|
return &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,
|
|
parameterManager: parameterManager{
|
|
probe: probe,
|
|
params: parameterStore{
|
|
dir: filepath.Join(cfg.EngineDataDir, "conf.d"),
|
|
installed: postgresParametersFile,
|
|
lastGood: postgresLastGoodFile,
|
|
osUser: cfg.EngineUser,
|
|
engine: enginePostgres,
|
|
},
|
|
repairTimeout: parameterRepairTimeout,
|
|
repairPoll: parameterRepairPoll,
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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 parser before
|
|
// the engine adopts it, and reloads. A refused value is rolled back here rather
|
|
// than left on the data volume, where it would survive every VM replace.
|
|
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 runs against the file in place, because the engine parses the
|
|
// datadir's own include_dir. The window that leaves is closed by the rollback
|
|
// below and the last-known-good restore the agent runs at boot.
|
|
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 every include first and exits non-zero naming
|
|
// the file and line of an unknown parameter or an out-of-range value.
|
|
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.probe.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
|
|
}
|
|
|
|
func (e *postgresEngine) Unquiesce(ctx context.Context) error {
|
|
return e.releaseHold(ctx, "SELECT pg_backup_stop();\n")
|
|
}
|