refactor: move the RDS parameter catalog onto Engine

Completes the control-plane half of the engine seam ahead of a second
engine. Engine gains maxUsernameLen, its own parameter table and its own
cross-parameter checks, and the catalog accessors become methods on it,
so no path resolves parameters without naming an engine.

PostgreSQL's table, size-derived formulas and combination checks move to
engine_postgres.go. engines is also indexed by parameter-group family,
for the callers that hold only a family string. EnginePostgres() is
deleted: the guest's reserved-username re-check now resolves the engine
through LookupEngine once at agent startup.

No behavior change.
This commit is contained in:
Julian Sommer
2026-08-11 11:05:52 +10:00
parent 2f0eb734ba
commit 08189c581d
9 changed files with 539 additions and 485 deletions
+19 -1
View File
@@ -116,6 +116,10 @@ func lookupCredential(username string) (*syscall.Credential, error) {
// 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 {
// 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
@@ -152,8 +156,22 @@ const (
parameterRepairPoll = time.Second
)
// 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("postgres")
func mustLookupEngine(name string) handlers_rds.Engine {
engine, err := handlers_rds.LookupEngine(name)
if err != nil {
panic("rds-agent: " + err.Error())
}
return engine
}
func newPostgresEngine(cfg config, run commandRunner, startSess sessionRunner, probe *engineProbe) *postgresEngine {
e := &postgresEngine{
meta: postgresEngineMeta,
run: run,
startSess: startSess,
psql: filepath.Join(cfg.PGBin, "psql"),
@@ -184,7 +202,7 @@ func (e *postgresEngine) SetPassword(ctx context.Context, username, password str
// 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 := handlers_rds.EnginePostgres().ValidateUsernameNotReserved(username); err != nil {
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
+29 -3
View File
@@ -23,12 +23,39 @@ type Engine struct {
reservedUsernames []string
// Prefixes the engine reserves for internal roles.
reservedUsernamePrefixes []string
// The engine's own identifier limit for a role name. The character rule is
// shared across engines; the length is not.
maxUsernameLen int
// The engine's parameter table, keyed by parameter name. The generic spec
// machinery is shared; only the table and its formulas are per-engine.
catalog map[string]ParameterSpec
// Cross-parameter checks a resolved set must satisfy, which are the
// combinations the engine itself would refuse to start under.
validateCombinations func([]Parameter) error
}
var engines = map[string]Engine{
enginePostgres.Name: enginePostgres,
}
// The same registry keyed by parameter-group family, for the callers that hold
// only a family string and have no instance to derive an engine from. Family
// and engine are 1:1 by construction, so one registry serves both.
var enginesByFamily = indexEnginesByFamily()
func indexEnginesByFamily() map[string]Engine {
out := make(map[string]Engine, len(engines))
for _, engine := range engines {
family := engine.ParameterGroupFamily()
if _, exists := out[family]; exists {
panic("rds: parameter group family " + family + " is claimed by two engines")
}
out[family] = engine
}
return out
}
// The pinned version an AMI lookup resolves against, which is the major alone:
// the AMI carries the major, and a minor is chosen by the image build.
func (e Engine) EngineVersion() string {
@@ -78,9 +105,9 @@ func (e Engine) ValidateMasterUsername(username string) error {
if username == "" {
return awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "MasterUsername is required")
}
if len(username) > maxMasterUsernameLen {
if len(username) > e.maxUsernameLen {
return awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
"MasterUsername must be at most %d characters", maxMasterUsernameLen)
"MasterUsername must be at most %d characters", e.maxUsernameLen)
}
if !isLetter(rune(username[0])) {
return awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "MasterUsername must begin with a letter")
@@ -140,7 +167,6 @@ func ValidateMasterUserPassword(password string) error {
}
const (
maxMasterUsernameLen = 63
minMasterPasswordLen = 8
maxMasterPasswordLen = 128
)
+435 -4
View File
@@ -1,5 +1,12 @@
package handlers_rds
import (
"strconv"
"strings"
"github.com/mulgadc/spinifex/spinifex/awserrors"
)
// PostgreSQL 18 is the pinned v1 major, matching the rds-postgres AMI preset.
// A new major is a new AMI plus a bump here, never a runtime upgrade.
var enginePostgres = Engine{
@@ -12,9 +19,433 @@ var enginePostgres = Engine{
// administrative privileges through.
reservedUsernames: []string{"rdsadmin", "postgres", "rds_superuser"},
reservedUsernamePrefixes: []string{"pg_"},
// NAMEDATALEN-1, where the engine's own limit and AWS's documented one
// coincide.
maxUsernameLen: 63,
catalog: postgresParameterCatalog,
validateCombinations: validatePostgresParameterCombinations,
}
// The pinned engine as a value, for the in-guest agent: it enforces the same
// reserved-role set before altering a role as the cluster superuser, and a
// lookup by name there could only fail silently into an empty set.
func EnginePostgres() Engine { return enginePostgres }
// The curated PostgreSQL 18 table: a genuinely validated subset rather than the
// engine's full ~350 GUCs, because a static parameter the engine refuses at
// startup is a boot loop with the bad config already on the data volume.
//
// The platform-owned settings (port, listen_addresses, ssl*,
// unix_socket_directories, data_directory) are deliberately absent rather than
// present-and-unmodifiable, because they are not the customer's to set.
var postgresParameterCatalog = buildParameterCatalog(
// Connections. max_connections is what a size-derived default matters most
// for: RDS's own formula is LEAST({DBInstanceClassMemory/9531392}, 5000).
ParameterSpec{
Name: "max_connections", DataType: paramTypeInteger, ApplyType: ApplyTypeStatic,
IsModifiable: true, Min: 6, Max: 5000,
DefaultFor: maxConnectionsFor,
MaxFor: maxConnectionsCeilingFor,
Description: "Maximum number of concurrent connections to the database server.",
},
ParameterSpec{
Name: "superuser_reserved_connections", DataType: paramTypeInteger, ApplyType: ApplyTypeStatic,
IsModifiable: true, Min: 0, Max: 100, Default: "3",
Description: "Connection slots reserved for superusers.",
},
ParameterSpec{
Name: "idle_in_transaction_session_timeout", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 2147483647, Default: "0", Unit: "ms",
Description: "Milliseconds an idle open transaction may live before it is aborted; 0 disables.",
},
ParameterSpec{
Name: "statement_timeout", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 2147483647, Default: "0", Unit: "ms",
Description: "Milliseconds a statement may run before it is aborted; 0 disables.",
},
ParameterSpec{
Name: "lock_timeout", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 2147483647, Default: "0", Unit: "ms",
Description: "Milliseconds to wait for a lock before failing the statement; 0 disables.",
},
ParameterSpec{
Name: "tcp_keepalives_idle", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 3600, Default: "0", Unit: "s",
Description: "Idle seconds before the server sends a TCP keepalive; 0 takes the system default.",
},
// Memory. Every one of these is a fraction of class memory on real RDS, so a
// literal tuned for the large end makes db.t3.micro fail to start.
ParameterSpec{
Name: "shared_buffers", DataType: paramTypeInteger, ApplyType: ApplyTypeStatic,
IsModifiable: true, Min: 16, Max: 4194304, Unit: "8kB",
DefaultFor: sharedBuffersFor,
MaxFor: sharedBuffersCeilingFor,
Description: "Shared memory buffers the server uses, in 8 kB blocks.",
},
ParameterSpec{
Name: "effective_cache_size", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 1, Max: 4194304, Unit: "8kB",
DefaultFor: effectiveCacheSizeFor,
MaxFor: effectiveCacheSizeCeilingFor,
Description: "Planner assumption about the disk cache available to one query, in 8 kB blocks.",
},
// Per sort or hash rather than per connection, so RDS leaves it a literal
// even in the memory group; a size-derived default here would multiply by
// max_connections and by the parallel workers under each of them.
ParameterSpec{
Name: "work_mem", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 64, Max: 2147483647, Default: "4096", Unit: "kB",
Description: "Memory one sort or hash operation may use before spilling to disk, in kB.",
},
ParameterSpec{
Name: "maintenance_work_mem", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 1024, Max: 2147483647, Unit: "kB",
DefaultFor: maintenanceWorkMemFor,
MaxFor: maintenanceWorkMemCeilingFor,
Description: "Memory a VACUUM, CREATE INDEX or ALTER TABLE ADD FOREIGN KEY may use, in kB.",
},
ParameterSpec{
Name: "temp_buffers", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 100, Max: 1073741823, Default: "1024", Unit: "8kB",
Description: "Per-session buffers for temporary tables, in 8 kB blocks.",
},
ParameterSpec{
Name: "max_prepared_transactions", DataType: paramTypeInteger, ApplyType: ApplyTypeStatic,
IsModifiable: true, Min: 0, Max: 262143, Default: "0",
Description: "Maximum simultaneously prepared transactions; 0 disables two-phase commit.",
},
// Parallelism. Bounded by the class's vCPU count on a real deployment, but
// PostgreSQL degrades gracefully when they exceed it, so literals are honest.
ParameterSpec{
Name: "max_worker_processes", DataType: paramTypeInteger, ApplyType: ApplyTypeStatic,
IsModifiable: true, Min: 0, Max: 262143, Default: "8",
Description: "Maximum background processes the server may start.",
},
ParameterSpec{
Name: "max_parallel_workers", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 1024, Default: "8",
Description: "Maximum workers the server may use for parallel operations.",
},
ParameterSpec{
Name: "max_parallel_workers_per_gather", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 1024, Default: "2",
Description: "Maximum parallel workers one Gather node may start.",
},
// WAL and checkpoints.
ParameterSpec{
Name: "wal_level", DataType: paramTypeEnum, ApplyType: ApplyTypeStatic,
IsModifiable: true, Enum: []string{"minimal", "replica", "logical"}, Default: "replica",
Description: "How much information is written to the WAL.",
},
ParameterSpec{
Name: "synchronous_commit", DataType: paramTypeEnum, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Enum: []string{"off", "local", "remote_write", "on", "remote_apply"}, Default: "on",
Description: "How much WAL processing must complete before a commit returns.",
},
// Alpine's PostgreSQL 18 package is built with both optional compression
// libraries, so every engine method is safe to expose.
ParameterSpec{
Name: "wal_compression", DataType: paramTypeEnum, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Enum: []string{"off", "pglz", "lz4", "zstd", "on"}, Default: "off",
Description: "Compression method for full-page images written to the WAL.",
},
// The image uses PostgreSQL's 16 MiB WAL segments; both size settings must
// hold at least two segments or startup rejects the configuration.
ParameterSpec{
Name: "max_wal_size", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 32, Max: 2097151, Default: "1024", Unit: "MB",
Description: "WAL size that triggers a checkpoint, in MB.",
},
ParameterSpec{
Name: "min_wal_size", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 32, Max: 2097151, Default: "80", Unit: "MB",
Description: "WAL size below which old segments are recycled rather than removed, in MB.",
},
ParameterSpec{
Name: "checkpoint_timeout", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 30, Max: 86400, Default: "300", Unit: "s",
Description: "Maximum seconds between automatic WAL checkpoints.",
},
ParameterSpec{
Name: "checkpoint_completion_target", DataType: paramTypeReal, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 1, Default: "0.9",
Description: "Fraction of the checkpoint interval a checkpoint's writes are spread over.",
},
ParameterSpec{
Name: "wal_buffers", DataType: paramTypeInteger, ApplyType: ApplyTypeStatic,
IsModifiable: true, Min: -1, Max: 262143, Default: "-1", Unit: "8kB",
Description: "Shared memory used for WAL not yet written, in 8 kB blocks; -1 derives it from shared_buffers.",
},
ParameterSpec{
Name: "max_wal_senders", DataType: paramTypeInteger, ApplyType: ApplyTypeStatic,
IsModifiable: true, Min: 0, Max: 262143, Default: "10",
Description: "Maximum simultaneously running WAL sender processes.",
},
ParameterSpec{
Name: "max_replication_slots", DataType: paramTypeInteger, ApplyType: ApplyTypeStatic,
IsModifiable: true, Min: 0, Max: 262143, Default: "10",
Description: "Maximum replication slots the server may define.",
},
// Autovacuum. Turning it off is the single most reliable way to take a
// PostgreSQL database down slowly, so it is offered but its default is on.
ParameterSpec{
Name: "autovacuum", DataType: paramTypeBoolean, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Default: "on",
Description: "Whether the autovacuum launcher runs.",
},
// PostgreSQL 18 moved this setting to SIGHUP; worker slot allocation is now
// controlled separately by the postmaster-only autovacuum_worker_slots.
ParameterSpec{
Name: "autovacuum_max_workers", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 1, Max: 262143, Default: "3",
Description: "Maximum autovacuum worker processes running at once.",
},
ParameterSpec{
Name: "autovacuum_naptime", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 1, Max: 2147483, Default: "60", Unit: "s",
Description: "Seconds between autovacuum runs on any one database.",
},
ParameterSpec{
Name: "autovacuum_vacuum_threshold", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 2147483647, Default: "50",
Description: "Row updates or deletes before a table is vacuumed.",
},
ParameterSpec{
Name: "autovacuum_vacuum_scale_factor", DataType: paramTypeReal, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 100, Default: "0.2",
Description: "Fraction of the table size added to autovacuum_vacuum_threshold.",
},
ParameterSpec{
Name: "autovacuum_analyze_threshold", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 2147483647, Default: "50",
Description: "Row inserts, updates or deletes before a table is analyzed.",
},
ParameterSpec{
Name: "autovacuum_analyze_scale_factor", DataType: paramTypeReal, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 100, Default: "0.1",
Description: "Fraction of the table size added to autovacuum_analyze_threshold.",
},
ParameterSpec{
Name: "autovacuum_vacuum_cost_limit", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: -1, Max: 10000, Default: "-1",
Description: "Cost budget one autovacuum worker spends before sleeping; -1 takes vacuum_cost_limit.",
},
// Planner.
// The engine's own ceiling on a planner cost is DBL_MAX, which is not a bound
// worth reporting: a cost past four digits already makes the plan choice
// insensitive to it, so the range stays one a customer can read.
ParameterSpec{
Name: "random_page_cost", DataType: paramTypeReal, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 10000, Default: "1.1",
Description: "Planner estimate of the cost of a non-sequentially fetched page.",
},
ParameterSpec{
Name: "seq_page_cost", DataType: paramTypeReal, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 10000, Default: "1",
Description: "Planner estimate of the cost of a sequentially fetched page.",
},
ParameterSpec{
Name: "effective_io_concurrency", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 1000, Default: "16",
Description: "Concurrent disk I/O operations the planner assumes are useful.",
},
ParameterSpec{
Name: "default_statistics_target", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 1, Max: 10000, Default: "100",
Description: "Default statistics target for table columns without one of their own.",
},
ParameterSpec{
Name: "jit", DataType: paramTypeBoolean, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Default: "on",
Description: "Whether JIT compilation may be used for qualifying queries.",
},
// Logging. The destination and the collector are platform-owned; what is
// logged is the customer's.
ParameterSpec{
Name: "log_min_duration_statement", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: -1, Max: 2147483647, Default: "-1", Unit: "ms",
Description: "Milliseconds a statement must run to be logged; 0 logs every statement, -1 disables.",
},
ParameterSpec{
Name: "log_statement", DataType: paramTypeEnum, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Enum: []string{"none", "ddl", "mod", "all"}, Default: "none",
Description: "Which SQL statements are written to the log.",
},
ParameterSpec{
Name: "log_min_messages", DataType: paramTypeEnum, ApplyType: ApplyTypeDynamic,
IsModifiable: true,
Enum: []string{"debug5", "debug4", "debug3", "debug2", "debug1", "info", "notice",
"warning", "error", "log", "fatal", "panic"},
Default: "warning",
Description: "Lowest message severity written to the server log.",
},
ParameterSpec{
Name: "log_connections", DataType: paramTypeBoolean, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Default: "off",
Description: "Whether each successful connection attempt is logged.",
},
ParameterSpec{
Name: "log_disconnections", DataType: paramTypeBoolean, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Default: "off",
Description: "Whether the end of each session is logged, with its duration.",
},
ParameterSpec{
Name: "log_lock_waits", DataType: paramTypeBoolean, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Default: "on",
Description: "Whether a session waiting longer than deadlock_timeout for a lock is logged.",
},
ParameterSpec{
Name: "log_temp_files", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: -1, Max: 2147483647, Default: "-1", Unit: "kB",
Description: "Size in kB above which a temporary file's removal is logged; 0 logs all, -1 disables.",
},
ParameterSpec{
Name: "log_autovacuum_min_duration", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: -1, Max: 2147483647, Default: "-1", Unit: "ms",
Description: "Milliseconds an autovacuum action must run to be logged; -1 disables.",
},
// Timeouts and locale.
ParameterSpec{
Name: "deadlock_timeout", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 1, Max: 2147483647, Default: "1000", Unit: "ms",
Description: "Milliseconds to wait on a lock before checking for a deadlock.",
},
ParameterSpec{
Name: "timezone", DataType: paramTypeString, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Default: "UTC",
Description: "Time zone the server displays and interprets timestamps in.",
},
ParameterSpec{
Name: "datestyle", DataType: paramTypeString, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Default: "ISO, MDY",
Description: "Display format for date and time values.",
},
ParameterSpec{
Name: "track_activity_query_size", DataType: paramTypeInteger, ApplyType: ApplyTypeStatic,
IsModifiable: true, Min: 100, Max: 1048576, Default: "1024", Unit: "B",
Description: "Bytes of each running query pg_stat_activity retains.",
},
ParameterSpec{
Name: "track_io_timing", DataType: paramTypeBoolean, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Default: "on",
Description: "Whether block read and write times are collected.",
},
)
// The bytes of class memory an RDS parameter formula divides. Real RDS exposes
// it as {DBInstanceClassMemory}, and the resolver evaluates the formulas here
// rather than passing them to the engine.
const mibToBytes = 1024 * 1024
// shared_buffers = {DBInstanceClassMemory/32768}, in 8 kB blocks — a quarter of
// class memory, which is RDS's own default.
func sharedBuffersFor(memoryMiB int64) string {
return strconv.FormatInt(clampInt64(memoryMiB*mibToBytes/32768, 16, 4194304), 10)
}
// effective_cache_size = {DBInstanceClassMemory/16384}, in 8 kB blocks — half of
// class memory, the planner's assumption about what the OS is caching.
func effectiveCacheSizeFor(memoryMiB int64) string {
return strconv.FormatInt(clampInt64(memoryMiB*mibToBytes/16384, 1, 4194304), 10)
}
// max_connections = LEAST({DBInstanceClassMemory/9531392}, 5000). The floor is
// what keeps db.t3.micro above superuser_reserved_connections plus the workers
// autovacuum and replication need, which is where the smallest class would
// otherwise fail to start.
func maxConnectionsFor(memoryMiB int64) string {
return strconv.FormatInt(clampInt64(memoryMiB*mibToBytes/9531392, 20, 5000), 10)
}
// maintenance_work_mem = {DBInstanceClassMemory/63963136}, in kB. Capped so a
// large class does not let autovacuum_max_workers reserve the whole machine.
func maintenanceWorkMemFor(memoryMiB int64) string {
return strconv.FormatInt(clampInt64(memoryMiB*mibToBytes/63963136*1024, 16384, 2097152), 10)
}
// Class ceilings deliberately leave substantial headroom above the defaults.
// They prevent obviously impossible large-class literals without prescribing
// production tuning for values the guest can support.
func sharedBuffersCeilingFor(memoryMiB int64) int64 {
return clampInt64(memoryMiB*1024/8*3/4, 16, 4194304)
}
func effectiveCacheSizeCeilingFor(memoryMiB int64) int64 {
return clampInt64(memoryMiB*1024/8, 1, 4194304)
}
func maxConnectionsCeilingFor(memoryMiB int64) int64 {
defaults := clampInt64(memoryMiB*mibToBytes/9531392, 20, 5000)
return clampInt64(defaults*4, 20, 5000)
}
func maintenanceWorkMemCeilingFor(memoryMiB int64) int64 {
return clampInt64(memoryMiB*1024/2, 16384, 2147483647)
}
func clampInt64(v, lo, hi int64) int64 {
return min(max(v, lo), hi)
}
func validatePostgresParameterCombinations(params []Parameter) error {
values := make(map[string]string, len(params))
for _, param := range params {
values[param.Name] = strings.ToLower(strings.TrimSpace(param.Value))
}
maxWALSenders, err := resolvedInteger(values, "max_wal_senders")
if err != nil {
return err
}
maxReplicationSlots, err := resolvedInteger(values, "max_replication_slots")
if err != nil {
return err
}
if values["wal_level"] == "minimal" && (maxWALSenders != 0 || maxReplicationSlots != 0) {
return awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
"wal_level minimal requires max_wal_senders and max_replication_slots both to be 0")
}
maxConnections, err := resolvedInteger(values, "max_connections")
if err != nil {
return err
}
reservedConnections, err := resolvedInteger(values, "superuser_reserved_connections")
if err != nil {
return err
}
if reservedConnections >= maxConnections {
return awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
"superuser_reserved_connections must be less than max_connections")
}
maxWorkerProcesses, err := resolvedInteger(values, "max_worker_processes")
if err != nil {
return err
}
const (
postgres18MaxBackends = 262143
postgres18AutovacuumWorkerSlots = 16
postgres18SpecialWorkerProcs = 2
)
backends := maxConnections + postgres18AutovacuumWorkerSlots + maxWorkerProcesses + maxWALSenders + postgres18SpecialWorkerProcs
if backends > postgres18MaxBackends {
return awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
"max_connections, max_worker_processes and max_wal_senders reserve too many server processes")
}
return nil
}
func resolvedInteger(values map[string]string, name string) (int64, error) {
value, ok := values[name]
if !ok {
return 0, awserrors.Errorf(awserrors.ErrorServerInternal,
"resolved parameter set is missing %s", name)
}
n, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return 0, awserrors.Errorf(awserrors.ErrorServerInternal,
"resolved parameter %s has invalid integer value %q", name, value)
}
return n, nil
}
+1 -1
View File
@@ -54,7 +54,7 @@ func TestEngineValidateMasterUsername(t *testing.T) {
"empty": "",
"leading digit": "1user",
"hyphen": "app-user",
"too long": strings.Repeat("a", maxMasterUsernameLen+1),
"too long": strings.Repeat("a", enginePostgres.maxUsernameLen+1),
"reserved": "rdsadmin",
"reserved cased": "RDSAdmin",
// The cluster superuser and the group role the master's administrative
+18 -437
View File
@@ -15,11 +15,9 @@ import (
"github.com/mulgadc/spinifex/spinifex/instancetypes"
)
// The static PostgreSQL 18 parameter catalog, following the EKS add-on catalog
// pattern: a curated in-binary table rather than the engine's full ~350 GUCs.
// A curated subset that is genuinely validated is worth more than the whole set
// passed through unchecked — a static parameter the engine refuses at startup
// is a boot loop, and the bad config is on the persistent data volume.
// The engine-neutral half of the parameter catalog: the spec type, its parsing
// and range checks, and the resolver. Each engine supplies its own table, its
// size-derived formulas and its combination checks; everything here is shared.
// When a parameter takes effect. Static settings are stored and reported
// pending-reboot; dynamic ones are adopted by a reload.
@@ -82,366 +80,6 @@ type ParameterSpec struct {
Unit string
}
// The catalog, keyed by parameter name. Every entry here is one a customer has a
// real reason to tune; the platform-owned settings (port, listen_addresses,
// ssl*, unix_socket_directories, data_directory) are deliberately absent rather
// than present-and-unmodifiable, because they are not the customer's to set.
var parameterCatalog = buildParameterCatalog(
// Connections. max_connections is what a size-derived default matters most
// for: RDS's own formula is LEAST({DBInstanceClassMemory/9531392}, 5000).
ParameterSpec{
Name: "max_connections", DataType: paramTypeInteger, ApplyType: ApplyTypeStatic,
IsModifiable: true, Min: 6, Max: 5000,
DefaultFor: maxConnectionsFor,
MaxFor: maxConnectionsCeilingFor,
Description: "Maximum number of concurrent connections to the database server.",
},
ParameterSpec{
Name: "superuser_reserved_connections", DataType: paramTypeInteger, ApplyType: ApplyTypeStatic,
IsModifiable: true, Min: 0, Max: 100, Default: "3",
Description: "Connection slots reserved for superusers.",
},
ParameterSpec{
Name: "idle_in_transaction_session_timeout", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 2147483647, Default: "0", Unit: "ms",
Description: "Milliseconds an idle open transaction may live before it is aborted; 0 disables.",
},
ParameterSpec{
Name: "statement_timeout", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 2147483647, Default: "0", Unit: "ms",
Description: "Milliseconds a statement may run before it is aborted; 0 disables.",
},
ParameterSpec{
Name: "lock_timeout", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 2147483647, Default: "0", Unit: "ms",
Description: "Milliseconds to wait for a lock before failing the statement; 0 disables.",
},
ParameterSpec{
Name: "tcp_keepalives_idle", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 3600, Default: "0", Unit: "s",
Description: "Idle seconds before the server sends a TCP keepalive; 0 takes the system default.",
},
// Memory. Every one of these is a fraction of class memory on real RDS, so a
// literal tuned for the large end makes db.t3.micro fail to start.
ParameterSpec{
Name: "shared_buffers", DataType: paramTypeInteger, ApplyType: ApplyTypeStatic,
IsModifiable: true, Min: 16, Max: 4194304, Unit: "8kB",
DefaultFor: sharedBuffersFor,
MaxFor: sharedBuffersCeilingFor,
Description: "Shared memory buffers the server uses, in 8 kB blocks.",
},
ParameterSpec{
Name: "effective_cache_size", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 1, Max: 4194304, Unit: "8kB",
DefaultFor: effectiveCacheSizeFor,
MaxFor: effectiveCacheSizeCeilingFor,
Description: "Planner assumption about the disk cache available to one query, in 8 kB blocks.",
},
// Per sort or hash rather than per connection, so RDS leaves it a literal
// even in the memory group; a size-derived default here would multiply by
// max_connections and by the parallel workers under each of them.
ParameterSpec{
Name: "work_mem", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 64, Max: 2147483647, Default: "4096", Unit: "kB",
Description: "Memory one sort or hash operation may use before spilling to disk, in kB.",
},
ParameterSpec{
Name: "maintenance_work_mem", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 1024, Max: 2147483647, Unit: "kB",
DefaultFor: maintenanceWorkMemFor,
MaxFor: maintenanceWorkMemCeilingFor,
Description: "Memory a VACUUM, CREATE INDEX or ALTER TABLE ADD FOREIGN KEY may use, in kB.",
},
ParameterSpec{
Name: "temp_buffers", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 100, Max: 1073741823, Default: "1024", Unit: "8kB",
Description: "Per-session buffers for temporary tables, in 8 kB blocks.",
},
ParameterSpec{
Name: "max_prepared_transactions", DataType: paramTypeInteger, ApplyType: ApplyTypeStatic,
IsModifiable: true, Min: 0, Max: 262143, Default: "0",
Description: "Maximum simultaneously prepared transactions; 0 disables two-phase commit.",
},
// Parallelism. Bounded by the class's vCPU count on a real deployment, but
// PostgreSQL degrades gracefully when they exceed it, so literals are honest.
ParameterSpec{
Name: "max_worker_processes", DataType: paramTypeInteger, ApplyType: ApplyTypeStatic,
IsModifiable: true, Min: 0, Max: 262143, Default: "8",
Description: "Maximum background processes the server may start.",
},
ParameterSpec{
Name: "max_parallel_workers", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 1024, Default: "8",
Description: "Maximum workers the server may use for parallel operations.",
},
ParameterSpec{
Name: "max_parallel_workers_per_gather", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 1024, Default: "2",
Description: "Maximum parallel workers one Gather node may start.",
},
// WAL and checkpoints.
ParameterSpec{
Name: "wal_level", DataType: paramTypeEnum, ApplyType: ApplyTypeStatic,
IsModifiable: true, Enum: []string{"minimal", "replica", "logical"}, Default: "replica",
Description: "How much information is written to the WAL.",
},
ParameterSpec{
Name: "synchronous_commit", DataType: paramTypeEnum, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Enum: []string{"off", "local", "remote_write", "on", "remote_apply"}, Default: "on",
Description: "How much WAL processing must complete before a commit returns.",
},
// Alpine's PostgreSQL 18 package is built with both optional compression
// libraries, so every engine method is safe to expose.
ParameterSpec{
Name: "wal_compression", DataType: paramTypeEnum, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Enum: []string{"off", "pglz", "lz4", "zstd", "on"}, Default: "off",
Description: "Compression method for full-page images written to the WAL.",
},
// The image uses PostgreSQL's 16 MiB WAL segments; both size settings must
// hold at least two segments or startup rejects the configuration.
ParameterSpec{
Name: "max_wal_size", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 32, Max: 2097151, Default: "1024", Unit: "MB",
Description: "WAL size that triggers a checkpoint, in MB.",
},
ParameterSpec{
Name: "min_wal_size", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 32, Max: 2097151, Default: "80", Unit: "MB",
Description: "WAL size below which old segments are recycled rather than removed, in MB.",
},
ParameterSpec{
Name: "checkpoint_timeout", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 30, Max: 86400, Default: "300", Unit: "s",
Description: "Maximum seconds between automatic WAL checkpoints.",
},
ParameterSpec{
Name: "checkpoint_completion_target", DataType: paramTypeReal, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 1, Default: "0.9",
Description: "Fraction of the checkpoint interval a checkpoint's writes are spread over.",
},
ParameterSpec{
Name: "wal_buffers", DataType: paramTypeInteger, ApplyType: ApplyTypeStatic,
IsModifiable: true, Min: -1, Max: 262143, Default: "-1", Unit: "8kB",
Description: "Shared memory used for WAL not yet written, in 8 kB blocks; -1 derives it from shared_buffers.",
},
ParameterSpec{
Name: "max_wal_senders", DataType: paramTypeInteger, ApplyType: ApplyTypeStatic,
IsModifiable: true, Min: 0, Max: 262143, Default: "10",
Description: "Maximum simultaneously running WAL sender processes.",
},
ParameterSpec{
Name: "max_replication_slots", DataType: paramTypeInteger, ApplyType: ApplyTypeStatic,
IsModifiable: true, Min: 0, Max: 262143, Default: "10",
Description: "Maximum replication slots the server may define.",
},
// Autovacuum. Turning it off is the single most reliable way to take a
// PostgreSQL database down slowly, so it is offered but its default is on.
ParameterSpec{
Name: "autovacuum", DataType: paramTypeBoolean, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Default: "on",
Description: "Whether the autovacuum launcher runs.",
},
// PostgreSQL 18 moved this setting to SIGHUP; worker slot allocation is now
// controlled separately by the postmaster-only autovacuum_worker_slots.
ParameterSpec{
Name: "autovacuum_max_workers", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 1, Max: 262143, Default: "3",
Description: "Maximum autovacuum worker processes running at once.",
},
ParameterSpec{
Name: "autovacuum_naptime", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 1, Max: 2147483, Default: "60", Unit: "s",
Description: "Seconds between autovacuum runs on any one database.",
},
ParameterSpec{
Name: "autovacuum_vacuum_threshold", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 2147483647, Default: "50",
Description: "Row updates or deletes before a table is vacuumed.",
},
ParameterSpec{
Name: "autovacuum_vacuum_scale_factor", DataType: paramTypeReal, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 100, Default: "0.2",
Description: "Fraction of the table size added to autovacuum_vacuum_threshold.",
},
ParameterSpec{
Name: "autovacuum_analyze_threshold", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 2147483647, Default: "50",
Description: "Row inserts, updates or deletes before a table is analyzed.",
},
ParameterSpec{
Name: "autovacuum_analyze_scale_factor", DataType: paramTypeReal, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 100, Default: "0.1",
Description: "Fraction of the table size added to autovacuum_analyze_threshold.",
},
ParameterSpec{
Name: "autovacuum_vacuum_cost_limit", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: -1, Max: 10000, Default: "-1",
Description: "Cost budget one autovacuum worker spends before sleeping; -1 takes vacuum_cost_limit.",
},
// Planner.
// The engine's own ceiling on a planner cost is DBL_MAX, which is not a bound
// worth reporting: a cost past four digits already makes the plan choice
// insensitive to it, so the range stays one a customer can read.
ParameterSpec{
Name: "random_page_cost", DataType: paramTypeReal, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 10000, Default: "1.1",
Description: "Planner estimate of the cost of a non-sequentially fetched page.",
},
ParameterSpec{
Name: "seq_page_cost", DataType: paramTypeReal, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 10000, Default: "1",
Description: "Planner estimate of the cost of a sequentially fetched page.",
},
ParameterSpec{
Name: "effective_io_concurrency", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 0, Max: 1000, Default: "16",
Description: "Concurrent disk I/O operations the planner assumes are useful.",
},
ParameterSpec{
Name: "default_statistics_target", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 1, Max: 10000, Default: "100",
Description: "Default statistics target for table columns without one of their own.",
},
ParameterSpec{
Name: "jit", DataType: paramTypeBoolean, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Default: "on",
Description: "Whether JIT compilation may be used for qualifying queries.",
},
// Logging. The destination and the collector are platform-owned; what is
// logged is the customer's.
ParameterSpec{
Name: "log_min_duration_statement", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: -1, Max: 2147483647, Default: "-1", Unit: "ms",
Description: "Milliseconds a statement must run to be logged; 0 logs every statement, -1 disables.",
},
ParameterSpec{
Name: "log_statement", DataType: paramTypeEnum, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Enum: []string{"none", "ddl", "mod", "all"}, Default: "none",
Description: "Which SQL statements are written to the log.",
},
ParameterSpec{
Name: "log_min_messages", DataType: paramTypeEnum, ApplyType: ApplyTypeDynamic,
IsModifiable: true,
Enum: []string{"debug5", "debug4", "debug3", "debug2", "debug1", "info", "notice",
"warning", "error", "log", "fatal", "panic"},
Default: "warning",
Description: "Lowest message severity written to the server log.",
},
ParameterSpec{
Name: "log_connections", DataType: paramTypeBoolean, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Default: "off",
Description: "Whether each successful connection attempt is logged.",
},
ParameterSpec{
Name: "log_disconnections", DataType: paramTypeBoolean, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Default: "off",
Description: "Whether the end of each session is logged, with its duration.",
},
ParameterSpec{
Name: "log_lock_waits", DataType: paramTypeBoolean, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Default: "on",
Description: "Whether a session waiting longer than deadlock_timeout for a lock is logged.",
},
ParameterSpec{
Name: "log_temp_files", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: -1, Max: 2147483647, Default: "-1", Unit: "kB",
Description: "Size in kB above which a temporary file's removal is logged; 0 logs all, -1 disables.",
},
ParameterSpec{
Name: "log_autovacuum_min_duration", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: -1, Max: 2147483647, Default: "-1", Unit: "ms",
Description: "Milliseconds an autovacuum action must run to be logged; -1 disables.",
},
// Timeouts and locale.
ParameterSpec{
Name: "deadlock_timeout", DataType: paramTypeInteger, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Min: 1, Max: 2147483647, Default: "1000", Unit: "ms",
Description: "Milliseconds to wait on a lock before checking for a deadlock.",
},
ParameterSpec{
Name: "timezone", DataType: paramTypeString, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Default: "UTC",
Description: "Time zone the server displays and interprets timestamps in.",
},
ParameterSpec{
Name: "datestyle", DataType: paramTypeString, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Default: "ISO, MDY",
Description: "Display format for date and time values.",
},
ParameterSpec{
Name: "track_activity_query_size", DataType: paramTypeInteger, ApplyType: ApplyTypeStatic,
IsModifiable: true, Min: 100, Max: 1048576, Default: "1024", Unit: "B",
Description: "Bytes of each running query pg_stat_activity retains.",
},
ParameterSpec{
Name: "track_io_timing", DataType: paramTypeBoolean, ApplyType: ApplyTypeDynamic,
IsModifiable: true, Default: "on",
Description: "Whether block read and write times are collected.",
},
)
// The bytes of class memory an RDS parameter formula divides. Real RDS exposes
// it as {DBInstanceClassMemory}, and the resolver evaluates the formulas here
// rather than passing them to the engine.
const mibToBytes = 1024 * 1024
// shared_buffers = {DBInstanceClassMemory/32768}, in 8 kB blocks — a quarter of
// class memory, which is RDS's own default.
func sharedBuffersFor(memoryMiB int64) string {
return strconv.FormatInt(clampInt64(memoryMiB*mibToBytes/32768, 16, 4194304), 10)
}
// effective_cache_size = {DBInstanceClassMemory/16384}, in 8 kB blocks — half of
// class memory, the planner's assumption about what the OS is caching.
func effectiveCacheSizeFor(memoryMiB int64) string {
return strconv.FormatInt(clampInt64(memoryMiB*mibToBytes/16384, 1, 4194304), 10)
}
// max_connections = LEAST({DBInstanceClassMemory/9531392}, 5000). The floor is
// what keeps db.t3.micro above superuser_reserved_connections plus the workers
// autovacuum and replication need, which is where the smallest class would
// otherwise fail to start.
func maxConnectionsFor(memoryMiB int64) string {
return strconv.FormatInt(clampInt64(memoryMiB*mibToBytes/9531392, 20, 5000), 10)
}
// maintenance_work_mem = {DBInstanceClassMemory/63963136}, in kB. Capped so a
// large class does not let autovacuum_max_workers reserve the whole machine.
func maintenanceWorkMemFor(memoryMiB int64) string {
return strconv.FormatInt(clampInt64(memoryMiB*mibToBytes/63963136*1024, 16384, 2097152), 10)
}
// Class ceilings deliberately leave substantial headroom above the defaults.
// They prevent obviously impossible large-class literals without prescribing
// production tuning for values the guest can support.
func sharedBuffersCeilingFor(memoryMiB int64) int64 {
return clampInt64(memoryMiB*1024/8*3/4, 16, 4194304)
}
func effectiveCacheSizeCeilingFor(memoryMiB int64) int64 {
return clampInt64(memoryMiB*1024/8, 1, 4194304)
}
func maxConnectionsCeilingFor(memoryMiB int64) int64 {
defaults := clampInt64(memoryMiB*mibToBytes/9531392, 20, 5000)
return clampInt64(defaults*4, 20, 5000)
}
func maintenanceWorkMemCeilingFor(memoryMiB int64) int64 {
return clampInt64(memoryMiB*1024/2, 16384, 2147483647)
}
func clampInt64(v, lo, hi int64) int64 {
return min(max(v, lo), hi)
}
// Indexes the specs by name and fails the build-equivalent — process start — on
// a malformed entry, so a catalog typo cannot reach a customer's create.
func buildParameterCatalog(specs ...ParameterSpec) map[string]ParameterSpec {
@@ -467,15 +105,15 @@ func buildParameterCatalog(specs ...ParameterSpec) map[string]ParameterSpec {
// The catalog entry for a parameter name, or false when the engine has no such
// setting or it is one this platform does not expose.
func LookupParameter(name string) (ParameterSpec, bool) {
spec, ok := parameterCatalog[strings.ToLower(strings.TrimSpace(name))]
func (e Engine) LookupParameter(name string) (ParameterSpec, bool) {
spec, ok := e.catalog[strings.ToLower(strings.TrimSpace(name))]
return spec, ok
}
// Sorted, so a describe returns the same order on every call and Terraform does
// not read a reshuffle as drift.
func CatalogParameterNames() []string {
return slices.Sorted(maps.Keys(parameterCatalog))
func (e Engine) CatalogParameterNames() []string {
return slices.Sorted(maps.Keys(e.catalog))
}
// The engine default for one parameter at one instance class: the literal, or
@@ -549,8 +187,8 @@ func rejectFormulaValue(name, value string) error {
// catalog does not hold, one the platform owns, and one outside its own range
// are all rejected here — at the API, rather than by an engine that then refuses
// to start with the bad config already on the data volume.
func validateParameterValue(name, value string) (ParameterSpec, error) {
spec, ok := LookupParameter(name)
func (e Engine) validateParameterValue(name, value string) (ParameterSpec, error) {
spec, ok := e.LookupParameter(name)
if !ok {
return ParameterSpec{}, awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
"%q is not a parameter this engine exposes", name)
@@ -692,18 +330,22 @@ func rangeError(spec ParameterSpec, value string) error {
//
// Overrides are re-validated rather than trusted: a catalog whose bounds
// tightened must not keep handing the engine a value it would now reject.
func ResolveEffectiveParameters(instanceClass string, overrides map[string]string) ([]Parameter, error) {
func (e Engine) ResolveEffectiveParameters(instanceClass string, overrides map[string]string) ([]Parameter, error) {
if e.validateCombinations == nil {
return nil, awserrors.Errorf(awserrors.ErrorServerInternal,
"engine %s registers no parameter combination checks", e.Name)
}
memoryMiB, err := classMemoryMiB(instanceClass)
if err != nil {
return nil, err
}
names := CatalogParameterNames()
names := e.CatalogParameterNames()
resolved := make([]Parameter, 0, len(names))
for _, name := range names {
spec := parameterCatalog[name]
spec := e.catalog[name]
value := spec.DefaultAt(memoryMiB)
if override, ok := overrides[name]; ok {
if _, err := validateParameterValue(name, override); err != nil {
if _, err := e.validateParameterValue(name, override); err != nil {
return nil, err
}
if err := validateClassParameterValue(instanceClass, memoryMiB, spec, override); err != nil {
@@ -713,7 +355,7 @@ func ResolveEffectiveParameters(instanceClass string, overrides map[string]strin
}
resolved = append(resolved, Parameter{Name: name, Value: value})
}
if err := validateParameterCombinations(resolved); err != nil {
if err := e.validateCombinations(resolved); err != nil {
return nil, err
}
return resolved, nil
@@ -735,64 +377,3 @@ func validateClassParameterValue(instanceClass string, memoryMiB int64, spec Par
}
return nil
}
func validateParameterCombinations(params []Parameter) error {
values := make(map[string]string, len(params))
for _, param := range params {
values[param.Name] = strings.ToLower(strings.TrimSpace(param.Value))
}
maxWALSenders, err := resolvedInteger(values, "max_wal_senders")
if err != nil {
return err
}
maxReplicationSlots, err := resolvedInteger(values, "max_replication_slots")
if err != nil {
return err
}
if values["wal_level"] == "minimal" && (maxWALSenders != 0 || maxReplicationSlots != 0) {
return awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
"wal_level minimal requires max_wal_senders and max_replication_slots both to be 0")
}
maxConnections, err := resolvedInteger(values, "max_connections")
if err != nil {
return err
}
reservedConnections, err := resolvedInteger(values, "superuser_reserved_connections")
if err != nil {
return err
}
if reservedConnections >= maxConnections {
return awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
"superuser_reserved_connections must be less than max_connections")
}
maxWorkerProcesses, err := resolvedInteger(values, "max_worker_processes")
if err != nil {
return err
}
const (
postgres18MaxBackends = 262143
postgres18AutovacuumWorkerSlots = 16
postgres18SpecialWorkerProcs = 2
)
backends := maxConnections + postgres18AutovacuumWorkerSlots + maxWorkerProcesses + maxWALSenders + postgres18SpecialWorkerProcs
if backends > postgres18MaxBackends {
return awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
"max_connections, max_worker_processes and max_wal_senders reserve too many server processes")
}
return nil
}
func resolvedInteger(values map[string]string, name string) (int64, error) {
value, ok := values[name]
if !ok {
return 0, awserrors.Errorf(awserrors.ErrorServerInternal,
"resolved parameter set is missing %s", name)
}
n, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return 0, awserrors.Errorf(awserrors.ErrorServerInternal,
"resolved parameter %s has invalid integer value %q", name, value)
}
return n, nil
}
+24 -24
View File
@@ -19,15 +19,15 @@ func TestParameterCatalog_DefaultsSatisfyTheirOwnConstraintsAtEveryClass(t *test
memoryMiB, err := classMemoryMiB(class)
require.NoError(t, err)
for _, name := range CatalogParameterNames() {
spec, ok := LookupParameter(name)
for _, name := range enginePostgres.CatalogParameterNames() {
spec, ok := enginePostgres.LookupParameter(name)
require.True(t, ok)
value := spec.DefaultAt(memoryMiB)
assert.NotEmpty(t, value, "%s has an empty default at %s", name, class)
// Validated by the same function a customer's value goes through, so
// a default cannot be something the API would reject.
_, err := validateParameterValue(name, value)
_, err := enginePostgres.validateParameterValue(name, value)
assert.NoError(t, err, "the default %q of %s is not a value %s accepts", value, name, class)
}
})
@@ -37,8 +37,8 @@ func TestParameterCatalog_DefaultsSatisfyTheirOwnConstraintsAtEveryClass(t *test
// Every entry has to be a shape the resolver and the describe can both render,
// or a client reads a parameter with no type and no apply semantics.
func TestParameterCatalog_EntriesAreWellFormed(t *testing.T) {
for _, name := range CatalogParameterNames() {
spec, _ := LookupParameter(name)
for _, name := range enginePostgres.CatalogParameterNames() {
spec, _ := enginePostgres.LookupParameter(name)
assert.Equal(t, name, spec.Name, "the catalog key and the spec name disagree")
assert.Contains(t, []string{ApplyTypeStatic, ApplyTypeDynamic}, spec.ApplyType, "%s", name)
@@ -63,7 +63,7 @@ func TestParameterCatalog_SizeDerivedDefaultsScaleWithClassMemory(t *testing.T)
require.Less(t, smallest, largest)
for _, name := range []string{"shared_buffers", "effective_cache_size", "max_connections", "maintenance_work_mem"} {
spec, ok := LookupParameter(name)
spec, ok := enginePostgres.LookupParameter(name)
require.True(t, ok, "%s is expected to carry a computed default", name)
require.NotNil(t, spec.DefaultFor, "%s should be size-derived", name)
@@ -120,7 +120,7 @@ func TestValidateParameterValue_RejectsBadInput(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := validateParameterValue(tc.param, tc.value)
_, err := enginePostgres.validateParameterValue(tc.param, tc.value)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, awserrors.ValidErrorCodeFromError(err),
"the code has to survive resolution or the client sees a 500")
@@ -145,7 +145,7 @@ func TestValidateParameterValue_AcceptsPostgresStringValues(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := validateParameterValue(tc.param, tc.value)
_, err := enginePostgres.validateParameterValue(tc.param, tc.value)
assert.NoError(t, err)
})
}
@@ -153,7 +153,7 @@ func TestValidateParameterValue_AcceptsPostgresStringValues(t *testing.T) {
func TestValidateParameterValue_AcceptsEveryEnginesSpellingOfABoolean(t *testing.T) {
for _, value := range []string{"on", "OFF", "true", "False", "yes", "no", "1", "0"} {
_, err := validateParameterValue("autovacuum", value)
_, err := enginePostgres.validateParameterValue("autovacuum", value)
assert.NoError(t, err, "autovacuum rejected %q, which the engine accepts", value)
}
}
@@ -161,9 +161,9 @@ func TestValidateParameterValue_AcceptsEveryEnginesSpellingOfABoolean(t *testing
// A group's overrides are laid over the whole catalog, not merged into a subset:
// every parameter is present, and one the group does not set carries its default.
func TestResolveEffectiveParameters_OverlaysOverridesOnDefaults(t *testing.T) {
resolved, err := ResolveEffectiveParameters("db.t3.medium", map[string]string{"work_mem": "16384"})
resolved, err := enginePostgres.ResolveEffectiveParameters("db.t3.medium", map[string]string{"work_mem": "16384"})
require.NoError(t, err)
require.Len(t, resolved, len(CatalogParameterNames()))
require.Len(t, resolved, len(enginePostgres.CatalogParameterNames()))
values := map[string]string{}
for _, param := range resolved {
@@ -180,7 +180,7 @@ func TestResolveEffectiveParameters_OverlaysOverridesOnDefaults(t *testing.T) {
// Sorted output is what lets a re-resolve that changed nothing produce a
// byte-identical include, so an apply does not restart an engine for no reason.
func TestResolveEffectiveParameters_IsSortedAndCarriesNoFormula(t *testing.T) {
resolved, err := ResolveEffectiveParameters("db.m5.xlarge", nil)
resolved, err := enginePostgres.ResolveEffectiveParameters("db.m5.xlarge", nil)
require.NoError(t, err)
for i := 1; i < len(resolved); i++ {
@@ -195,7 +195,7 @@ func TestResolveEffectiveParameters_IsSortedAndCarriesNoFormula(t *testing.T) {
// A stored value the catalog would now reject must not keep being handed to the
// engine, so the resolve re-validates rather than trusting what was written.
func TestResolveEffectiveParameters_RevalidatesStoredOverrides(t *testing.T) {
_, err := ResolveEffectiveParameters("db.t3.medium", map[string]string{"max_connections": "999999"})
_, err := enginePostgres.ResolveEffectiveParameters("db.t3.medium", map[string]string{"max_connections": "999999"})
require.Error(t, err)
assert.Contains(t, err.Error(), "max_connections")
}
@@ -212,14 +212,14 @@ func TestResolveEffectiveParameters_BoundsSizeDerivedOverridesToTheClass(t *test
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ResolveEffectiveParameters("db.t3.micro", map[string]string{tt.name: tt.value})
_, err := enginePostgres.ResolveEffectiveParameters("db.t3.micro", map[string]string{tt.name: tt.value})
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, awserrors.ValidErrorCodeFromError(err))
assert.Contains(t, err.Error(), "db.t3.micro")
assert.Contains(t, err.Error(), tt.value)
assert.Contains(t, err.Error(), "ceiling")
_, err = ResolveEffectiveParameters("db.m5.xlarge", map[string]string{tt.name: tt.value})
_, err = enginePostgres.ResolveEffectiveParameters("db.m5.xlarge", map[string]string{tt.name: tt.value})
assert.NoError(t, err, "the same literal should fit the larger class")
})
}
@@ -252,7 +252,7 @@ func TestResolveEffectiveParameters_RejectsFatalCombinations(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ResolveEffectiveParameters("db.m5.xlarge", tt.overrides)
_, err := enginePostgres.ResolveEffectiveParameters("db.m5.xlarge", tt.overrides)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, awserrors.ValidErrorCodeFromError(err))
assert.Contains(t, err.Error(), tt.want)
@@ -261,22 +261,22 @@ func TestResolveEffectiveParameters_RejectsFatalCombinations(t *testing.T) {
}
func TestResolveEffectiveParameters_AcceptsMinimalWALWithoutReplication(t *testing.T) {
_, err := ResolveEffectiveParameters("db.t3.micro", map[string]string{
_, err := enginePostgres.ResolveEffectiveParameters("db.t3.micro", map[string]string{
"wal_level": "minimal", "max_wal_senders": "0", "max_replication_slots": "0",
})
require.NoError(t, err)
}
func TestParameterCatalog_Postgres18ApplyTypesAndBuiltCompressionMethods(t *testing.T) {
autovacuumWorkers, _ := LookupParameter("autovacuum_max_workers")
autovacuumWorkers, _ := enginePostgres.LookupParameter("autovacuum_max_workers")
assert.Equal(t, ApplyTypeDynamic, autovacuumWorkers.ApplyType)
walCompression, _ := LookupParameter("wal_compression")
walCompression, _ := enginePostgres.LookupParameter("wal_compression")
assert.ElementsMatch(t, []string{"off", "pglz", "lz4", "zstd", "on"}, walCompression.Enum)
}
func TestResolveEffectiveParameters_RejectsAnUnknownClass(t *testing.T) {
_, err := ResolveEffectiveParameters("db.x99.mega", nil)
_, err := enginePostgres.ResolveEffectiveParameters("db.x99.mega", nil)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, awserrors.ValidErrorCodeFromError(err),
"the code has to survive resolution or the client sees a 500")
@@ -285,15 +285,15 @@ func TestResolveEffectiveParameters_RejectsAnUnknownClass(t *testing.T) {
// AllowedValues is what tells a customer what an integer means, so a unitful
// parameter has to report its unit rather than a bare pair of numbers.
func TestParameterSpec_AllowedValuesDescribesTheConstraint(t *testing.T) {
shared, _ := LookupParameter("shared_buffers")
shared, _ := enginePostgres.LookupParameter("shared_buffers")
assert.Equal(t, "16-4194304 (8kB)", shared.AllowedValues())
logStatement, _ := LookupParameter("log_statement")
logStatement, _ := enginePostgres.LookupParameter("log_statement")
assert.Equal(t, "none,ddl,mod,all", logStatement.AllowedValues())
timezone, _ := LookupParameter("timezone")
timezone, _ := enginePostgres.LookupParameter("timezone")
assert.Empty(t, timezone.AllowedValues(), "a free-form string has no constraint to report")
target, _ := LookupParameter("checkpoint_completion_target")
target, _ := enginePostgres.LookupParameter("checkpoint_completion_target")
assert.True(t, strings.HasPrefix(target.AllowedValues(), "0-1"), "got %q", target.AllowedValues())
}
+8 -10
View File
@@ -153,7 +153,7 @@ func (s *Service) ModifyDBParameterGroup(ctx context.Context, input *rds.ModifyD
"at most %d parameters may be modified in one request, got %d", maxParametersPerModify, len(input.Parameters))
}
updates, err := validateParameterUpdates(input.Parameters)
updates, err := validateParameterUpdates(enginePostgres, input.Parameters)
if err != nil {
return nil, err
}
@@ -253,8 +253,8 @@ func (s *Service) DescribeDBParameters(ctx context.Context, input *rds.DescribeD
}
out := &rds.DescribeDBParametersOutput{}
for _, param := range CatalogParameterNames() {
spec, _ := LookupParameter(param)
for _, param := range enginePostgres.CatalogParameterNames() {
spec, _ := enginePostgres.LookupParameter(param)
override, isOverride := overrides[param]
value := override.Value
if !isOverride {
@@ -331,13 +331,13 @@ type parameterUpdate struct {
// Every entry is checked before any is written. A name repeated in one request
// keeps its last value, as AWS does.
func validateParameterUpdates(params []*rds.Parameter) ([]parameterUpdate, error) {
func validateParameterUpdates(engine Engine, params []*rds.Parameter) ([]parameterUpdate, error) {
byName := make(map[string]parameterUpdate, len(params))
for _, param := range params {
if param == nil {
return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "a parameter entry is empty")
}
spec, err := validateParameterValue(aws.StringValue(param.ParameterName), aws.StringValue(param.ParameterValue))
spec, err := engine.validateParameterValue(aws.StringValue(param.ParameterName), aws.StringValue(param.ParameterValue))
if err != nil {
return nil, err
}
@@ -437,10 +437,8 @@ func validateParameterGroupFamily(family string) (string, error) {
if trimmed == "" {
return enginePostgres.ParameterGroupFamily(), nil
}
for _, engine := range engines {
if trimmed == engine.ParameterGroupFamily() {
return engine.ParameterGroupFamily(), nil
}
if engine, ok := enginesByFamily[trimmed]; ok {
return engine.ParameterGroupFamily(), nil
}
return "", awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
"DBParameterGroupFamily %q is not offered; %s is the only supported family", family, enginePostgres.ParameterGroupFamily())
@@ -508,5 +506,5 @@ func (s *Service) resolveGroupParameters(ctx context.Context, kv jetstream.KeyVa
for name, override := range overrides {
values[name] = override.Value
}
return ResolveEffectiveParameters(instanceClass, values)
return enginePostgres.ResolveEffectiveParameters(instanceClass, values)
}
+4 -4
View File
@@ -80,7 +80,7 @@ func TestCreateDBParameterGroup_StoresAnEmptyGroup(t *testing.T) {
// A fresh group and the default group resolve to the same effective set,
// because a group holds overrides rather than a copy of the catalog.
params := describedParameters(t, h, testParameterGroup)
require.Len(t, params, len(CatalogParameterNames()))
require.Len(t, params, len(enginePostgres.CatalogParameterNames()))
for _, param := range params {
assert.Equal(t, ParameterSourceEngineDefault, aws.StringValue(param.Source))
}
@@ -441,7 +441,7 @@ func TestDescribeDBParameters_FiltersOnSource(t *testing.T) {
Source: aws.String(ParameterSourceEngineDefault),
}, testAccountID)
require.NoError(t, err)
assert.Len(t, defaults.Parameters, len(CatalogParameterNames())-1)
assert.Len(t, defaults.Parameters, len(enginePostgres.CatalogParameterNames())-1)
}
func TestDeleteDBParameterGroup_RemovesTheGroupAndItsValues(t *testing.T) {
@@ -527,7 +527,7 @@ func TestCreateDBInstance_ResolvesTheNamedGroupIntoTheBootstrapSet(t *testing.T)
rec := h.record(t, testDBInstanceID)
assert.Equal(t, testParameterGroup, rec.DBParameterGroupName)
require.Len(t, rec.Bootstrap.ResolvedParameters, len(CatalogParameterNames()))
require.Len(t, rec.Bootstrap.ResolvedParameters, len(enginePostgres.CatalogParameterNames()))
values := map[string]string{}
for _, param := range rec.Bootstrap.ResolvedParameters {
@@ -551,7 +551,7 @@ func TestCreateDBInstance_ResolvesTheDefaultGroupWhenNoneIsNamed(t *testing.T) {
rec := h.record(t, testDBInstanceID)
assert.Equal(t, testDefaultPG, rec.DBParameterGroupName)
assert.Len(t, rec.Bootstrap.ResolvedParameters, len(CatalogParameterNames()))
assert.Len(t, rec.Bootstrap.ResolvedParameters, len(enginePostgres.CatalogParameterNames()))
}
func TestCreateDBInstance_RejectsAnUnknownParameterGroup(t *testing.T) {
+1 -1
View File
@@ -382,7 +382,7 @@ func TestApplyPendingModifications_ParameterGroupChangeRevertsTheOldGroupsValues
for _, param := range issued[0].Parameters {
applied[param.Name] = param.Value
}
workMem, _ := LookupParameter("work_mem")
workMem, _ := enginePostgres.LookupParameter("work_mem")
assert.Equal(t, workMem.Default, applied["work_mem"],
"the old group's value should have reverted to the catalog default")
assert.False(t, h.record(t).ParametersRolledBack, "a successful corrected apply clears the rollback state")