diff --git a/cmd/rds-agent/engine.go b/cmd/rds-agent/engine.go index d5979a728..7c93c7e47 100644 --- a/cmd/rds-agent/engine.go +++ b/cmd/rds-agent/engine.go @@ -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 diff --git a/spinifex/handlers/rds/engine.go b/spinifex/handlers/rds/engine.go index 1870c6e44..2b806495a 100644 --- a/spinifex/handlers/rds/engine.go +++ b/spinifex/handlers/rds/engine.go @@ -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 ) diff --git a/spinifex/handlers/rds/engine_postgres.go b/spinifex/handlers/rds/engine_postgres.go index cf43309e3..94e6e3de9 100644 --- a/spinifex/handlers/rds/engine_postgres.go +++ b/spinifex/handlers/rds/engine_postgres.go @@ -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 +} diff --git a/spinifex/handlers/rds/engine_test.go b/spinifex/handlers/rds/engine_test.go index 3a8f899ae..dd4580681 100644 --- a/spinifex/handlers/rds/engine_test.go +++ b/spinifex/handlers/rds/engine_test.go @@ -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 diff --git a/spinifex/handlers/rds/paramcatalog.go b/spinifex/handlers/rds/paramcatalog.go index dd3863df7..39ab3b523 100644 --- a/spinifex/handlers/rds/paramcatalog.go +++ b/spinifex/handlers/rds/paramcatalog.go @@ -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 -} diff --git a/spinifex/handlers/rds/paramcatalog_test.go b/spinifex/handlers/rds/paramcatalog_test.go index 67cd1b5f4..5a4f56aec 100644 --- a/spinifex/handlers/rds/paramcatalog_test.go +++ b/spinifex/handlers/rds/paramcatalog_test.go @@ -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()) } diff --git a/spinifex/handlers/rds/parametergroup.go b/spinifex/handlers/rds/parametergroup.go index 866c6fe1b..c1d1ae0e0 100644 --- a/spinifex/handlers/rds/parametergroup.go +++ b/spinifex/handlers/rds/parametergroup.go @@ -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) } diff --git a/spinifex/handlers/rds/parametergroup_test.go b/spinifex/handlers/rds/parametergroup_test.go index 268310291..f90a71fde 100644 --- a/spinifex/handlers/rds/parametergroup_test.go +++ b/spinifex/handlers/rds/parametergroup_test.go @@ -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) { diff --git a/spinifex/handlers/rds/pending_test.go b/spinifex/handlers/rds/pending_test.go index 5f4f347a8..f790b6933 100644 --- a/spinifex/handlers/rds/pending_test.go +++ b/spinifex/handlers/rds/pending_test.go @@ -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")