refactor: extract RDS DB parameter group owner

Move the parameter group and override records, keys and listing into
domains/rds/parametergroup behind an Owner. Delete asks an owner-declared
Dependants interface; instance-side resolution reads through its own
parameterGroupValues interface. SetOverrides refuses an unknown group so
no override key is written without its group. Stored shape, keys, public
errors and propagation are unchanged.
This commit is contained in:
joshsiv-mulga
2026-10-08 21:51:08 +10:00
parent 5548a7039f
commit d9c3f3ca6f
15 changed files with 619 additions and 263 deletions
+1
View File
@@ -77,6 +77,7 @@ services:
- spinifex/handlers/ecs
- spinifex/handlers/rds
- spinifex/domains/rds/subnetgroup
- spinifex/domains/rds/parametergroup
- spinifex/domains/rds/engine
- spinifex/domains/ecs/taskdefinition
- spinifex/handlers/eks
@@ -0,0 +1,315 @@
package parametergroup
import (
"context"
"errors"
"fmt"
"log/slog"
"maps"
"slices"
"strings"
"time"
rdsengine "github.com/mulgadc/spinifex/spinifex/domains/rds/engine"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
// Dependants reports the sorted identifiers of the DB instances in the account that name the group,
// current or pending.
type Dependants interface {
InstancesUsing(ctx context.Context, accountID, name string) ([]string, error)
}
// Bucket opens the account's RDS bucket, which holds the group's records.
type Bucket func(ctx context.Context, accountID string) (*kvstore.Bucket, error)
// Owner is the sole writer of DB parameter group records and their overrides.
type Owner struct {
dependants Dependants
bucket Bucket
}
// New returns an Owner.
func New(dependants Dependants, bucket Bucket) *Owner {
return &Owner{dependants: dependants, bucket: bucket}
}
// Spec is a validated create request. It starts with no overrides: every value is a catalog default
// until SetOverrides stores one, so a fresh group and the default group resolve to the same effective set.
type Spec struct {
Name string
Family string
Description string
Tags map[string]string
}
// Create stores a new group, or fails with DBParameterGroupAlreadyExists when the name is taken.
func (o *Owner) Create(ctx context.Context, accountID string, spec Spec) (*Record, error) {
kv, err := o.bucket(ctx, accountID)
if err != nil {
return nil, err
}
now := time.Now().UTC()
rec := &Record{
Name: spec.Name,
AccountID: accountID,
Family: spec.Family,
Description: spec.Description,
Tags: spec.Tags,
CreatedAt: now,
UpdatedAt: now,
}
// Create rather than Set, so two concurrent creates of one name have exactly
// one winner instead of both reporting success over each other's family.
if _, err := kvstore.On[Record](kv).Create(ctx, MetaKey(spec.Name), rec); err != nil {
if errors.Is(err, kvstore.ErrExists) {
return nil, awserrors.Errorf(awserrors.ErrorDBParameterGroupAlreadyExists,
"DB parameter group %s already exists", spec.Name)
}
return nil, err
}
slog.InfoContext(ctx, "rds: DB parameter group created",
"dbParameterGroup", spec.Name, "accountId", accountID, "family", spec.Family)
return rec, nil
}
// Get returns the group, including the implicit default group synthesised for an engine that has
// never been created, or DBParameterGroupNotFound.
func (o *Owner) Get(ctx context.Context, accountID, name string) (*Record, error) {
kv, err := o.bucket(ctx, accountID)
if err != nil {
return nil, err
}
rec, _, err := get(ctx, kv, accountID, name)
return rec, err
}
// List returns every group in the account, including the implicit default group of every registered
// engine, sorted by name and deduplicated: a customer group that happens to carry a default's name
// (which Create refuses) would otherwise be reported twice.
func (o *Owner) List(ctx context.Context, accountID string) ([]Record, error) {
kv, err := o.bucket(ctx, accountID)
if err != nil {
return nil, err
}
names, err := storedNames(ctx, kv)
if err != nil {
return nil, err
}
for _, engineName := range rdsengine.SupportedEngines() {
engine, err := rdsengine.LookupEngine(engineName)
if err != nil {
return nil, err
}
names = append(names, engine.DefaultParameterGroupName())
}
slices.Sort(names)
names = slices.Compact(names)
recs := make([]Record, 0, len(names))
for _, name := range names {
rec, _, err := get(ctx, kv, accountID, name)
if err != nil {
// Deleted between the listing and this read; the same answer a describe
// one tick later would give.
if awserrors.IsErrorCode(err, awserrors.ErrorDBParameterGroupNotFound) {
continue
}
return nil, err
}
recs = append(recs, *rec)
}
return recs, nil
}
// Overrides returns the stored overrides of one parameter group, keyed by parameter name.
func (o *Owner) Overrides(ctx context.Context, accountID, name string) (map[string]Override, error) {
kv, err := o.bucket(ctx, accountID)
if err != nil {
return nil, err
}
return overrides(ctx, kv, name)
}
// SetOverrides stores each override at its own key, one write per parameter, so a modify touching one
// setting cannot clobber a concurrent change to another. It refuses a default group, which has no
// stored values of its own, and an unknown group, so no override is written without its group.
func (o *Owner) SetOverrides(ctx context.Context, accountID, name string, values []Override) error {
if _, ok := rdsengine.EngineForDefaultParameterGroup(name); ok {
return awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
"DB parameter group %s is a default group and cannot be modified", name)
}
kv, err := o.bucket(ctx, accountID)
if err != nil {
return err
}
if _, _, err := get(ctx, kv, accountID, name); err != nil {
return err
}
now := time.Now().UTC()
for _, value := range values {
value.UpdatedAt = now
if err := kvstore.On[Override](kv).Set(ctx, ParamKey(name, value.Name), &value); err != nil {
return err
}
}
return nil
}
// Delete is refused for a default group, and while any instance still names the group, including one
// that is only deleting or only pending it: releasing it early would let a teardown lose the record of
// its configuration, and would make destroy ordering ambiguous. The values are removed before the
// group's own record, so a crash between the two leaves orphaned parameter keys rather than a group a
// later create could inherit silently.
func (o *Owner) Delete(ctx context.Context, accountID, name string) error {
if _, ok := rdsengine.EngineForDefaultParameterGroup(name); ok {
return awserrors.Errorf(awserrors.ErrorDBParameterGroupInvalidState,
"DB parameter group %s is a default group and cannot be deleted", name)
}
kv, err := o.bucket(ctx, accountID)
if err != nil {
return err
}
if _, _, err := get(ctx, kv, accountID, name); err != nil {
return err
}
users, err := o.dependants.InstancesUsing(ctx, accountID, name)
if err != nil {
return err
}
if len(users) > 0 {
return awserrors.Errorf(awserrors.ErrorDBParameterGroupInvalidState,
"DB parameter group %s is still used by %s", name, strings.Join(users, ", "))
}
values, err := overrides(ctx, kv, name)
if err != nil {
return err
}
for _, param := range slices.Sorted(maps.Keys(values)) {
if err := kv.Delete(ctx, ParamKey(name, param)); err != nil {
return fmt.Errorf("rds: delete parameter %s of group %s: %w", param, name, err)
}
}
if err := kv.Delete(ctx, MetaKey(name)); err != nil {
return fmt.Errorf("rds: delete DB parameter group %s: %w", name, err)
}
slog.InfoContext(ctx, "rds: DB parameter group deleted", "dbParameterGroup", name, "accountId", accountID)
return nil
}
// The stored record, or the lazily materialised default group. A default group is synthesised rather
// than written on read: the record carries nothing a write would preserve, and materialising it on a
// read would make a read path a writer for no gain. The record plus its revision, so a caller doing a
// CAS update is not forced into a second read.
func get(ctx context.Context, kv *kvstore.Bucket, accountID, name string) (*Record, uint64, error) {
rec, rev, err := kvstore.On[Record](kv).Get(ctx, MetaKey(name))
if err == nil {
return rec, rev, nil
}
if !errors.Is(err, kvstore.ErrNotFound) {
return nil, 0, err
}
if engine, ok := rdsengine.EngineForDefaultParameterGroup(name); ok {
return defaultRecord(engine, accountID), 0, nil
}
return nil, 0, awserrors.Errorf(awserrors.ErrorDBParameterGroupNotFound, "DB parameter group %s not found", name)
}
// The implicit group, identical for every account. It carries no tags and no stored values, so it
// resolves to the catalog defaults alone.
func defaultRecord(engine rdsengine.Engine, accountID string) *Record {
return &Record{
Name: engine.DefaultParameterGroupName(),
AccountID: accountID,
Family: engine.ParameterGroupFamily(),
Description: fmt.Sprintf("Default parameter group for %s", engine.ParameterGroupFamily()),
}
}
// overrides returns the stored overrides of one group, keyed by parameter name, against an
// already-opened bucket.
func overrides(ctx context.Context, kv *kvstore.Bucket, name string) (map[string]Override, error) {
paramPrefix := ParamsPrefix(name)
names, err := paramNames(ctx, kv, paramPrefix)
if err != nil {
return nil, err
}
out := make(map[string]Override, len(names))
for _, param := range names {
v, _, err := kvstore.On[Override](kv).Get(ctx, paramPrefix+param)
if errors.Is(err, kvstore.ErrNotFound) {
// Reset between the listing and this read; the same answer a resolve one
// tick later would give.
continue
}
if err != nil {
return nil, err
}
out[param] = *v
}
return out, nil
}
// storedNames walks the .../meta keys, which is what makes a group's own record findable among the
// per-parameter keys hanging off the same prefix.
func storedNames(ctx context.Context, kv *kvstore.Bucket) ([]string, error) {
keys, err := bucketKeys(ctx, kv)
if err != nil {
return nil, err
}
names := make([]string, 0, len(keys))
for _, key := range keys {
rest, prefixed := strings.CutPrefix(key, prefix)
name, suffixed := strings.CutSuffix(rest, "/meta")
if !prefixed || !suffixed {
continue
}
if name == "" || strings.Contains(name, "/") {
continue
}
names = append(names, name)
}
return names, nil
}
// paramNames returns the leaf names directly under paramPrefix.
func paramNames(ctx context.Context, kv *kvstore.Bucket, paramPrefix string) ([]string, error) {
keys, err := bucketKeys(ctx, kv)
if err != nil {
return nil, err
}
names := make([]string, 0, len(keys))
for _, key := range keys {
name, ok := strings.CutPrefix(key, paramPrefix)
if !ok {
continue
}
if name == "" || strings.Contains(name, "/") {
continue
}
names = append(names, name)
}
return names, nil
}
// An empty bucket yields no keys rather than an error, so a first-ever list answers with an empty
// result instead of failing.
func bucketKeys(ctx context.Context, b *kvstore.Bucket) ([]string, error) {
kv, err := b.KV(ctx)
if err != nil {
return nil, err
}
keys, err := kv.Keys(ctx)
if err != nil {
if errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil
}
return nil, fmt.Errorf("rds: list keys: %w", err)
}
return keys, nil
}
@@ -0,0 +1,165 @@
package parametergroup
import (
"context"
"errors"
"strings"
"testing"
"github.com/mulgadc/spinifex/internal/testkit"
rdsengine "github.com/mulgadc/spinifex/spinifex/domains/rds/engine"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
testAccount = "123456789012"
testGroup = "app-params"
)
type fakeDependants struct {
users []string
err error
}
func (f fakeDependants) InstancesUsing(context.Context, string, string) ([]string, error) {
return f.users, f.err
}
func newOwner(t *testing.T, dependants Dependants) *Owner {
t.Helper()
_, _, js := testutil.StartTestJetStream(t)
b := kvstore.NewBucket(js, kvstore.Config{Name: "rds-account-" + testAccount, History: 1})
return New(dependants, func(context.Context, string) (*kvstore.Bucket, error) { return b, nil })
}
func testFamily(t *testing.T) string {
t.Helper()
engine, err := rdsengine.LookupEngine("postgres")
require.NoError(t, err)
return engine.ParameterGroupFamily()
}
func TestKeys_SitUnderPrefix(t *testing.T) {
t.Parallel()
assert.Equal(t, "db-parameter-groups/pg16/meta", MetaKey("pg16"))
assert.Equal(t, "db-parameter-groups/pg16/params/shared_buffers", ParamKey("pg16", "shared_buffers"))
assert.True(t, strings.HasPrefix(MetaKey("x"), Prefix()))
assert.True(t, strings.HasPrefix(ParamKey("x", "p"), ParamsPrefix("x")))
// A group's values must not be reachable by listing groups' meta records, or
// a list would report one group per parameter.
assert.False(t, strings.HasPrefix(ParamKey("x", "p"), MetaKey("x")))
}
func TestCreate_DuplicateNameIsRefusedWithoutOverwrite(t *testing.T) {
t.Parallel()
owner := newOwner(t, fakeDependants{})
family := testFamily(t)
_, err := owner.Create(t.Context(), testAccount, Spec{Name: testGroup, Family: family, Description: "first"})
require.NoError(t, err)
_, err = owner.Create(t.Context(), testAccount, Spec{Name: testGroup, Family: family, Description: "second"})
assert.Equal(t, awserrors.ErrorDBParameterGroupAlreadyExists, awserrors.ValidErrorCodeFromError(err))
rec, err := owner.Get(t.Context(), testAccount, testGroup)
require.NoError(t, err)
assert.Equal(t, "first", rec.Description)
}
func TestDelete_RefusesWhileDependantsNameTheGroup(t *testing.T) {
t.Parallel()
owner := newOwner(t, fakeDependants{users: []string{"orders-db", "users-db"}})
family := testFamily(t)
_, err := owner.Create(t.Context(), testAccount, Spec{Name: testGroup, Family: family, Description: "d"})
require.NoError(t, err)
require.NoError(t, owner.SetOverrides(t.Context(), testAccount, testGroup,
[]Override{{Name: "shared_buffers", Value: "512MB"}}))
err = owner.Delete(t.Context(), testAccount, testGroup)
assert.Equal(t, awserrors.ErrorDBParameterGroupInvalidState, awserrors.ValidErrorCodeFromError(err))
assert.ErrorContains(t, err, "orders-db, users-db")
rec, err := owner.Get(t.Context(), testAccount, testGroup)
require.NoError(t, err)
assert.Equal(t, "d", rec.Description)
overrides, err := owner.Overrides(t.Context(), testAccount, testGroup)
require.NoError(t, err)
assert.Len(t, overrides, 1)
}
// The in-use guard fails closed: when the instance side cannot answer, the
// group and its overrides stay rather than being deleted on an unknown answer.
func TestDelete_KeepsTheGroupWhenTheInUseCheckFails(t *testing.T) {
t.Parallel()
owner := newOwner(t, fakeDependants{err: errors.New("instance scan failed")})
family := testFamily(t)
_, err := owner.Create(t.Context(), testAccount, Spec{Name: testGroup, Family: family, Description: "d"})
require.NoError(t, err)
require.ErrorContains(t, owner.Delete(t.Context(), testAccount, testGroup), "instance scan failed")
_, err = owner.Get(t.Context(), testAccount, testGroup)
require.NoError(t, err)
}
// A default name is synthesised on read rather than written, so Get never
// materialises a record a later Create would then collide with.
func TestGet_DefaultGroupSynthesisesWithoutWriting(t *testing.T) {
t.Parallel()
owner := newOwner(t, fakeDependants{})
engine, err := rdsengine.LookupEngine("postgres")
require.NoError(t, err)
name := engine.DefaultParameterGroupName()
rec, err := owner.Get(t.Context(), testAccount, name)
require.NoError(t, err)
assert.Equal(t, name, rec.Name)
assert.Equal(t, engine.ParameterGroupFamily(), rec.Family)
kv, err := owner.bucket(t.Context(), testAccount)
require.NoError(t, err)
names, err := storedNames(t.Context(), kv)
require.NoError(t, err)
assert.NotContains(t, names, name, "a synthesised default group must not be written to the bucket")
}
func TestDelete_RefusesADefaultGroup(t *testing.T) {
t.Parallel()
owner := newOwner(t, fakeDependants{})
engine, err := rdsengine.LookupEngine("postgres")
require.NoError(t, err)
err = owner.Delete(t.Context(), testAccount, engine.DefaultParameterGroupName())
assert.Equal(t, awserrors.ErrorDBParameterGroupInvalidState, awserrors.ValidErrorCodeFromError(err))
}
func TestSetOverrides_RefusesADefaultGroup(t *testing.T) {
t.Parallel()
owner := newOwner(t, fakeDependants{})
engine, err := rdsengine.LookupEngine("postgres")
require.NoError(t, err)
err = owner.SetOverrides(t.Context(), testAccount, engine.DefaultParameterGroupName(),
[]Override{{Name: "shared_buffers", Value: "512MB"}})
assert.Equal(t, awserrors.ErrorInvalidParameterValue, awserrors.ValidErrorCodeFromError(err))
}
func TestSetOverrides_UnknownGroupIsNotFoundAndWritesNothing(t *testing.T) {
t.Parallel()
owner := newOwner(t, fakeDependants{})
const name = "default.bogus"
err := owner.SetOverrides(t.Context(), testAccount, name,
[]Override{{Name: "work_mem", Value: "16384"}})
assert.Equal(t, awserrors.ErrorDBParameterGroupNotFound, awserrors.ValidErrorCodeFromError(err))
kv, err := owner.bucket(t.Context(), testAccount)
require.NoError(t, err)
keys, err := bucketKeys(t.Context(), kv)
require.NoError(t, err)
for _, key := range keys {
assert.False(t, strings.HasPrefix(key, ParamsPrefix(name)), "override key %s written for an unknown group", key)
}
}
@@ -0,0 +1,60 @@
// Package parametergroup owns the RDS DB parameter group: its durable record, the stored overrides
// that sit alongside it, and the in-use guard on its delete. Default groups are never stored; the
// engine catalogue is their only source.
package parametergroup
import "time"
const prefix = "db-parameter-groups/"
// Prefix returns the per-account key prefix under which every DB parameter group's keys live.
// A group's own record is at .../meta and its values hang off .../params/, so listing groups walks
// the meta keys.
func Prefix() string {
return prefix
}
// MetaKey returns the per-account KV key of a DB parameter group's own record.
func MetaKey(name string) string {
return prefix + name + "/meta"
}
// ParamsPrefix returns the prefix holding one key per parameter value rather than one blob, so a
// modify touching a single parameter cannot clobber a concurrent change to another.
func ParamsPrefix(name string) string {
return prefix + name + "/params/"
}
// ParamKey returns the per-account KV key of one parameter override in a DB parameter group.
func ParamKey(name, param string) string {
return ParamsPrefix(name) + param
}
// Record is the db-parameter-groups/{name}/meta record. The values themselves live one key each
// under .../params/, so a modify touching one parameter cannot clobber a concurrent change to
// another. A default group, synthesised rather than stored, carries no tags and no CreatedAt.
type Record struct {
Name string `json:"name"`
AccountID string `json:"accountId"`
Family string `json:"family"`
Description string `json:"description"`
Tags map[string]string `json:"tags,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (r *Record) GetTags() map[string]string { return r.Tags }
func (r *Record) SetTags(tags map[string]string) { r.Tags = tags }
// Override is one stored value, at db-parameter-groups/{name}/params/{key}. ApplyMethod is the
// customer's request rather than a fact: whether a change lands live is decided by the parameter's
// own ApplyType.
type Override struct {
Name string `json:"name"`
Value string `json:"value"`
ApplyMethod string `json:"applyMethod,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
}
+1 -1
View File
@@ -42,7 +42,7 @@ func (s *Service) CreateDBInstance(ctx context.Context, input *rds.CreateDBInsta
// does not exist leaves no record behind. The set is literals only: the
// class's memory has already been folded into every size-derived default, so
// the agent never sees a formula.
parameters, err := s.resolveGroupParameters(ctx, kv, accountID, req.Engine, req.DBParameterGroupName, req.InstanceClass)
parameters, err := s.resolveGroupParameters(ctx, accountID, req.Engine, req.DBParameterGroupName, req.InstanceClass)
if err != nil {
return nil, err
}
+1 -5
View File
@@ -242,11 +242,7 @@ func (s *Service) planModify(ctx context.Context, input *rds.ModifyDBInstanceInp
if err != nil {
return nil, err
}
kv, err := s.bucket(ctx, accountID)
if err != nil {
return nil, err
}
if _, err := s.resolveGroupParameters(ctx, kv, accountID, engine, targetGroup, targetClass); err != nil {
if _, err := s.resolveGroupParameters(ctx, accountID, engine, targetGroup, targetClass); err != nil {
return nil, err
}
}
+60 -135
View File
@@ -8,11 +8,11 @@ import (
"maps"
"slices"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/rds"
rdsengine "github.com/mulgadc/spinifex/spinifex/domains/rds/engine"
"github.com/mulgadc/spinifex/spinifex/domains/rds/parametergroup"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
)
@@ -69,31 +69,16 @@ func (s *Service) CreateDBParameterGroup(ctx context.Context, input *rds.CreateD
return nil, err
}
kv, err := s.bucket(ctx, accountID)
if err != nil {
return nil, err
}
now := time.Now().UTC()
rec := DBParameterGroupRecord{
rec, err := s.parameterGroups().Create(ctx, accountID, parametergroup.Spec{
Name: name,
AccountID: accountID,
Family: family,
Description: description,
Tags: tags,
CreatedAt: now,
UpdatedAt: now,
}
if err := createJSON(ctx, kv, DBParameterGroupMetaKey(name), &rec); err != nil {
if errors.Is(err, kvstore.ErrExists) {
return nil, awserrors.Errorf(awserrors.ErrorDBParameterGroupAlreadyExists,
"DB parameter group %s already exists", name)
}
})
if err != nil {
return nil, err
}
slog.InfoContext(ctx, "rds: DB parameter group created",
"dbParameterGroup", name, "accountId", accountID, "family", family)
return &rds.CreateDBParameterGroupOutput{DBParameterGroup: s.projectParameterGroupRecord(&rec)}, nil
return &rds.CreateDBParameterGroupOutput{DBParameterGroup: s.projectParameterGroupRecord(rec)}, nil
}
// DescribeDBParameterGroups reports the implicit default group alongside the customer's own, whether
@@ -107,12 +92,8 @@ func (s *Service) DescribeDBParameterGroups(ctx context.Context, input *rds.Desc
if _, err := ReadFilters(input.Filters, filterDBParameterGroupFamily, filterEngine); err != nil {
return nil, err
}
kv, err := s.bucket(ctx, accountID)
if err != nil {
return nil, err
}
if name := aws.StringValue(input.DBParameterGroupName); name != "" {
rec, _, err := getDBParameterGroup(ctx, kv, accountID, name)
rec, err := s.parameterGroups().Get(ctx, accountID, name)
if err != nil {
return nil, err
}
@@ -121,34 +102,15 @@ func (s *Service) DescribeDBParameterGroups(ctx context.Context, input *rds.Desc
}, nil
}
names, err := ListDBParameterGroupNames(ctx, kv)
// The default group is synthesised rather than read, so it appears exactly
// once whether or not a prior create persisted it.
recs, err := s.parameterGroups().List(ctx, accountID)
if err != nil {
return nil, err
}
// The default group is synthesised rather than read, so it appears exactly
// once whether or not a prior create persisted it.
for _, name := range rdsengine.SupportedEngines() {
engine, err := rdsengine.LookupEngine(name)
if err != nil {
return nil, err
}
names = append(names, engine.DefaultParameterGroupName())
}
slices.Sort(names)
names = slices.Compact(names)
groups := make([]*rds.DBParameterGroup, 0, len(names))
for _, name := range names {
rec, _, err := getDBParameterGroup(ctx, kv, accountID, name)
if err != nil {
// Deleted between the listing and this read; the same answer a describe
// one tick later would give.
if awserrors.IsErrorCode(err, awserrors.ErrorDBParameterGroupNotFound) {
continue
}
return nil, err
}
groups = append(groups, s.projectParameterGroupRecord(rec))
groups := make([]*rds.DBParameterGroup, 0, len(recs))
for i := range recs {
groups = append(groups, s.projectParameterGroupRecord(&recs[i]))
}
groups, next, err := Page(groups, parameterGroupPageKey, input.MaxRecords, input.Marker)
if err != nil {
@@ -181,15 +143,11 @@ 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))
}
kv, err := s.bucket(ctx, accountID)
if err != nil {
return nil, err
}
// The group is read before its values are checked because the engine that
// owns them comes from the group's family. Validating against anything else
// would store one engine's parameter into another's group and defer the
// failure to whichever instance next attached it.
rec, _, err := getDBParameterGroup(ctx, kv, accountID, name)
rec, err := s.parameterGroups().Get(ctx, accountID, name)
if err != nil {
return nil, err
}
@@ -202,18 +160,18 @@ func (s *Service) ModifyDBParameterGroup(ctx context.Context, input *rds.ModifyD
return nil, err
}
now := time.Now().UTC()
for _, update := range updates {
if err := putJSON(ctx, kv, DBParameterGroupParamKey(name, update.Name), &DBParameterRecord{
Name: update.Name,
Value: update.Value,
ApplyMethod: update.ApplyMethod,
UpdatedAt: now,
}); err != nil {
return nil, err
}
values := make([]parametergroup.Override, len(updates))
for i, update := range updates {
values[i] = parametergroup.Override{Name: update.Name, Value: update.Value, ApplyMethod: update.ApplyMethod}
}
if err := s.parameterGroups().SetOverrides(ctx, accountID, name, values); err != nil {
return nil, err
}
kv, err := s.bucket(ctx, accountID)
if err != nil {
return nil, err
}
if err := s.propagateParameterGroup(ctx, kv, accountID, name); err != nil {
return nil, err
}
@@ -276,11 +234,7 @@ func (s *Service) DescribeDBParameters(ctx context.Context, input *rds.DescribeD
return nil, err
}
kv, err := s.bucket(ctx, accountID)
if err != nil {
return nil, err
}
rec, _, err := getDBParameterGroup(ctx, kv, accountID, name)
rec, err := s.parameterGroups().Get(ctx, accountID, name)
if err != nil {
return nil, err
}
@@ -290,7 +244,7 @@ func (s *Service) DescribeDBParameters(ctx context.Context, input *rds.DescribeD
if err != nil {
return nil, err
}
overrides, err := ListDBParameterOverrides(ctx, kv, name)
overrides, err := s.parameterGroups().Overrides(ctx, accountID, name)
if err != nil {
return nil, err
}
@@ -350,42 +304,33 @@ func (s *Service) DeleteDBParameterGroup(ctx context.Context, input *rds.DeleteD
"DB parameter group %s is a default group and cannot be deleted", name)
}
kv, err := s.bucket(ctx, accountID)
if err := s.parameterGroups().Delete(ctx, accountID, name); err != nil {
return nil, err
}
return &rds.DeleteDBParameterGroupOutput{}, nil
}
// The group's lifecycle owner, bound to this Service's bucket.
func (s *Service) parameterGroups() *parametergroup.Owner {
return parametergroup.New(parameterGroupReferences{s}, s.bucket)
}
// The instance side of the group's in-use guard: current or pending attachment,
// matching the delete guard's own predicate. It reads instance records directly
// until instances have their own lifecycle owner.
type parameterGroupReferences struct{ s *Service }
var _ parametergroup.Dependants = parameterGroupReferences{}
func (r parameterGroupReferences) InstancesUsing(ctx context.Context, accountID, name string) ([]string, error) {
kv, err := r.s.bucket(ctx, accountID)
if err != nil {
return nil, err
}
if _, _, err := getDBParameterGroup(ctx, kv, accountID, name); err != nil {
return nil, err
}
users, err := instancesUsingGroup(ctx, kv, func(rec *DBInstanceRecord) bool {
return instancesUsingGroup(ctx, kv, func(rec *DBInstanceRecord) bool {
return rec.DBParameterGroupName == name ||
(rec.PendingModifiedValues != nil && rec.PendingModifiedValues.DBParameterGroupName == name)
})
if err != nil {
return nil, err
}
if len(users) > 0 {
return nil, awserrors.Errorf(awserrors.ErrorDBParameterGroupInvalidState,
"DB parameter group %s is still used by %s", name, strings.Join(users, ", "))
}
// The values go first: a crash between the two leaves orphaned parameter keys
// under a name a later create would then inherit silently.
overrides, err := ListDBParameterOverrides(ctx, kv, name)
if err != nil {
return nil, err
}
for _, param := range slices.Sorted(maps.Keys(overrides)) {
if err := kv.Delete(ctx, DBParameterGroupParamKey(name, param)); err != nil {
return nil, fmt.Errorf("rds: delete parameter %s of group %s: %w", param, name, err)
}
}
if err := kv.Delete(ctx, DBParameterGroupMetaKey(name)); err != nil {
return nil, fmt.Errorf("rds: delete DB parameter group %s: %w", name, err)
}
slog.InfoContext(ctx, "rds: DB parameter group deleted", "dbParameterGroup", name, "accountId", accountID)
return &rds.DeleteDBParameterGroupOutput{}, nil
}
// One validated override from a ModifyDBParameterGroup request.
@@ -450,36 +395,6 @@ func resolveApplyMethod(spec rdsengine.ParameterSpec, requested string) (string,
}
}
// The stored record, or the lazily materialised default group. A default group
// is synthesised rather than written on read: the record carries nothing a write
// would preserve, and materialising it on a describe would make a read path a
// writer for no gain.
func getDBParameterGroup(ctx context.Context, kv *kvstore.Bucket, accountID, name string) (*DBParameterGroupRecord, uint64, error) {
var rec DBParameterGroupRecord
rev, found, err := getJSONRevision(ctx, kv, DBParameterGroupMetaKey(name), &rec)
if err != nil {
return nil, 0, err
}
if found {
return &rec, rev, nil
}
if engine, ok := rdsengine.EngineForDefaultParameterGroup(name); ok {
return defaultParameterGroupRecord(engine, accountID), 0, nil
}
return nil, 0, awserrors.Errorf(awserrors.ErrorDBParameterGroupNotFound, "DB parameter group %s not found", name)
}
// The implicit group, identical for every account. It carries no tags and no
// stored values, so it resolves to the catalog defaults alone.
func defaultParameterGroupRecord(engine rdsengine.Engine, accountID string) *DBParameterGroupRecord {
return &DBParameterGroupRecord{
Name: engine.DefaultParameterGroupName(),
AccountID: accountID,
Family: engine.ParameterGroupFamily(),
Description: fmt.Sprintf("Default parameter group for %s", engine.ParameterGroupFamily()),
}
}
func isDefaultParameterGroupName(name string) bool {
return strings.HasPrefix(strings.ToLower(name), defaultParameterGroupPrefix)
}
@@ -532,7 +447,7 @@ func projectParameter(spec rdsengine.ParameterSpec, value, storedApplyMethod str
return out
}
func (s *Service) projectParameterGroupRecord(rec *DBParameterGroupRecord) *rds.DBParameterGroup {
func (s *Service) projectParameterGroupRecord(rec *parametergroup.Record) *rds.DBParameterGroup {
if rec == nil {
return nil
}
@@ -544,6 +459,15 @@ func (s *Service) projectParameterGroupRecord(rec *DBParameterGroupRecord) *rds.
}
}
// parameterGroupValues is the instance side's narrow read of a DB parameter group: its family and its
// stored overrides, satisfied by the group's lifecycle owner.
type parameterGroupValues interface {
Get(ctx context.Context, accountID, name string) (*parametergroup.Record, error)
Overrides(ctx context.Context, accountID, name string) (map[string]parametergroup.Override, error)
}
var _ parameterGroupValues = (*parametergroup.Owner)(nil)
// The effective set an instance of this class runs with under this group: catalog
// defaults evaluated at the class, overlaid with the group's stored overrides.
// A group that does not exist fails here rather than silently resolving to the
@@ -552,8 +476,9 @@ func (s *Service) projectParameterGroupRecord(rec *DBParameterGroupRecord) *rds.
// Every path that binds a group to an instance comes through here — create,
// modify, restore, the deferred apply and group propagation — so the
// cross-engine refusal is one check rather than five.
func (s *Service) resolveGroupParameters(ctx context.Context, kv *kvstore.Bucket, accountID string, engine rdsengine.Engine, group, instanceClass string) ([]Parameter, error) {
rec, _, err := getDBParameterGroup(ctx, kv, accountID, group)
func (s *Service) resolveGroupParameters(ctx context.Context, accountID string, engine rdsengine.Engine, group, instanceClass string) ([]Parameter, error) {
var groups parameterGroupValues = s.parameterGroups()
rec, err := groups.Get(ctx, accountID, group)
if err != nil {
return nil, err
}
@@ -562,7 +487,7 @@ func (s *Service) resolveGroupParameters(ctx context.Context, kv *kvstore.Bucket
"DB parameter group %s is of family %s, which cannot be used by a %s DB instance; it requires a group of family %s",
group, rec.Family, engine.Name, engine.ParameterGroupFamily())
}
overrides, err := ListDBParameterOverrides(ctx, kv, group)
overrides, err := groups.Overrides(ctx, accountID, group)
if err != nil {
return nil, err
}
@@ -6,6 +6,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/rds"
"github.com/mulgadc/spinifex/spinifex/domains/rds/parametergroup"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -87,7 +88,7 @@ func TestDBParameterGroupRecord_DecodesAHandWrittenBlob(t *testing.T) {
_, err = js.Put(t.Context(), "db-parameter-groups/"+testParameterGroup+"/params/work_mem", []byte(paramJSON))
require.NoError(t, err)
rec, _, err := getDBParameterGroup(t.Context(), kv, testAccountID, testParameterGroup)
rec, err := h.svc.parameterGroups().Get(t.Context(), testAccountID, testParameterGroup)
require.NoError(t, err)
assert.Equal(t, "tuned-pg", rec.Name)
assert.Equal(t, testAccountID, rec.AccountID)
@@ -95,7 +96,7 @@ func TestDBParameterGroupRecord_DecodesAHandWrittenBlob(t *testing.T) {
assert.Equal(t, "Tuned for the orders workload", rec.Description)
assert.Equal(t, map[string]string{"env": "prod"}, rec.Tags)
overrides, err := ListDBParameterOverrides(t.Context(), kv, testParameterGroup)
overrides, err := h.svc.parameterGroups().Overrides(t.Context(), testAccountID, testParameterGroup)
require.NoError(t, err)
override, ok := overrides["work_mem"]
require.True(t, ok)
@@ -120,8 +121,8 @@ func TestDescribeDBParameterGroups_TheDefaultGroupIsSynthesisedNotStored(t *test
kv, err := h.svc.bucket(t.Context(), testAccountID)
require.NoError(t, err)
var rec DBParameterGroupRecord
found, err := getJSON(t.Context(), kv, DBParameterGroupMetaKey(testDefaultPG), &rec)
var rec parametergroup.Record
found, err := getJSON(t.Context(), kv, parametergroup.MetaKey(testDefaultPG), &rec)
require.NoError(t, err)
assert.False(t, found, "a lazily-synthesised default group must not be written by a describe")
}
+5 -6
View File
@@ -8,6 +8,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/rds"
rdsengine "github.com/mulgadc/spinifex/spinifex/domains/rds/engine"
"github.com/mulgadc/spinifex/spinifex/domains/rds/parametergroup"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -422,7 +423,7 @@ func TestModifyDBParameterGroup_ReturnsAPropagationFailure(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), testDBID)
overrides, listErr := ListDBParameterOverrides(t.Context(), h.kv(t), testParameterGroup)
overrides, listErr := h.svc.parameterGroups().Overrides(t.Context(), testAccountID, testParameterGroup)
require.NoError(t, listErr)
assert.Equal(t, "16384", overrides["work_mem"].Value,
"the durable group edit remains available for a retry")
@@ -588,11 +589,11 @@ func TestDescribeDBParameters_DerivesApplyMethodForLegacyOverrides(t *testing.T)
require.NoError(t, err)
kv, err := h.svc.bucket(t.Context(), testAccountID)
require.NoError(t, err)
for _, rec := range []DBParameterRecord{
for _, rec := range []parametergroup.Override{
{Name: "work_mem", Value: "16384"},
{Name: "shared_buffers", Value: "65536"},
} {
require.NoError(t, putJSON(t.Context(), kv, DBParameterGroupParamKey(testParameterGroup, rec.Name), &rec))
require.NoError(t, putJSON(t.Context(), kv, parametergroup.ParamKey(testParameterGroup, rec.Name), &rec))
}
params := describedParameters(t, h, testParameterGroup)
@@ -688,11 +689,9 @@ func TestDeleteDBParameterGroup_RemovesTheGroupAndItsValues(t *testing.T) {
&rds.DeleteDBParameterGroupInput{DBParameterGroupName: aws.String(testParameterGroup)}, testAccountID)
require.NoError(t, err)
kv, err := h.svc.bucket(t.Context(), testAccountID)
require.NoError(t, err)
// Orphaned values would be silently inherited by a later group of the same
// name, which is a configuration nobody wrote.
overrides, err := ListDBParameterOverrides(t.Context(), kv, testParameterGroup)
overrides, err := h.svc.parameterGroups().Overrides(t.Context(), testAccountID, testParameterGroup)
require.NoError(t, err)
assert.Empty(t, overrides)
+1 -1
View File
@@ -155,7 +155,7 @@ func (s *Service) applyParameterGroup(ctx context.Context, kv *kvstore.Bucket, a
// Reached from a deferred modify, whose group was checked at request time,
// and from group propagation, where the binding was checked at attach. A
// family mismatch here is corrupt state rather than a bad request.
resolved, err := s.resolveGroupParameters(ctx, kv, accountID, engine, group, instanceClass)
resolved, err := s.resolveGroupParameters(ctx, accountID, engine, group, instanceClass)
if err != nil {
return s.recordParameterApplyFailure(ctx, kv, accountID, rec, group, err)
}
-31
View File
@@ -378,37 +378,6 @@ type Parameter struct {
Value string `json:"value" locationName:"Value"`
}
// DBParameterGroupRecord is the db-parameter-groups/{name}/meta record. The values themselves live one
// key each under .../params/, so a modify touching one parameter cannot clobber a concurrent change to
// another.
type DBParameterGroupRecord struct {
Name string `json:"name"`
AccountID string `json:"accountId"`
Family string `json:"family"`
Description string `json:"description"`
Tags map[string]string `json:"tags,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
var _ TaggedRecord = (*DBParameterGroupRecord)(nil)
func (r *DBParameterGroupRecord) GetTags() map[string]string { return r.Tags }
func (r *DBParameterGroupRecord) SetTags(tags map[string]string) { r.Tags = tags }
// DBParameterRecord is one stored override, at db-parameter-groups/{name}/params/{key}. ApplyMethod is
// the customer's request rather than a fact: whether a change lands live is decided by the parameter's
// own ApplyType.
type DBParameterRecord struct {
Name string `json:"name"`
Value string `json:"value"`
ApplyMethod string `json:"applyMethod,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
}
// EngineHealth is the agent-reported state of the database engine, separate from Status: the reconciler
// needs both to tell "stopped because we stopped it" from "stopped because it died".
type EngineHealth string
+1 -1
View File
@@ -59,7 +59,7 @@ func (s *Service) RestoreDBInstanceFromDBSnapshot(ctx context.Context, input *rd
}
// resolveRestoreRequest forces the engine to the snapshot's, so a group of
// another engine is refused here however the request named it.
parameters, err := s.resolveGroupParameters(ctx, kv, accountID, req.Engine, req.DBParameterGroupName, req.InstanceClass)
parameters, err := s.resolveGroupParameters(ctx, accountID, req.Engine, req.DBParameterGroupName, req.InstanceClass)
if err != nil {
return nil, err
}
-68
View File
@@ -101,28 +101,6 @@ func kvKeySegmentIdentifier(segment string) string {
return strings.ReplaceAll(segment, kvKeyColon, ":")
}
// DBParameterGroupsPrefix returns the parameter group prefix. A group's own record is at .../meta and
// its values hang off .../params/, so listing groups walks the meta keys.
func DBParameterGroupsPrefix() string {
return "db-parameter-groups/"
}
// DBParameterGroupMetaKey returns the KV key of a DB parameter group's own record.
func DBParameterGroupMetaKey(name string) string {
return fmt.Sprintf("%s%s/meta", DBParameterGroupsPrefix(), name)
}
// DBParameterGroupParamsPrefix returns the prefix holding one key per parameter value rather than one
// blob, so a ModifyDBParameterGroup touching a single parameter cannot clobber a concurrent change to another.
func DBParameterGroupParamsPrefix(name string) string {
return fmt.Sprintf("%s%s/params/", DBParameterGroupsPrefix(), name)
}
// DBParameterGroupParamKey returns the KV key of one parameter override in a DB parameter group.
func DBParameterGroupParamKey(name, param string) string {
return DBParameterGroupParamsPrefix(name) + param
}
// AutomatedBackupsRootPrefix returns the automated-backup index prefix, kept separate from db-snapshots/
// so the retention sweep enumerates only automated backups, ordered lexically by their timestamp suffix.
func AutomatedBackupsRootPrefix() string {
@@ -331,52 +309,6 @@ func ListDBSnapshotIDs(ctx context.Context, kv *kvstore.Bucket) ([]string, error
return names, nil
}
// ListDBParameterGroupNames walks the .../meta keys, which is what makes a group's own record findable
// among the per-parameter keys hanging off the same prefix.
func ListDBParameterGroupNames(ctx context.Context, kv *kvstore.Bucket) ([]string, error) {
keys, err := bucketKeys(ctx, kv)
if err != nil {
return nil, err
}
prefix := DBParameterGroupsPrefix()
names := make([]string, 0, len(keys))
for _, key := range keys {
rest, prefixed := strings.CutPrefix(key, prefix)
name, suffixed := strings.CutSuffix(rest, "/meta")
if !prefixed || !suffixed {
continue
}
if name == "" || strings.Contains(name, "/") {
continue
}
names = append(names, name)
}
return names, nil
}
// ListDBParameterOverrides returns the stored overrides of one parameter group, keyed by parameter name.
func ListDBParameterOverrides(ctx context.Context, kv *kvstore.Bucket, group string) (map[string]DBParameterRecord, error) {
prefix := DBParameterGroupParamsPrefix(group)
names, err := listNames(ctx, kv, prefix)
if err != nil {
return nil, err
}
out := make(map[string]DBParameterRecord, len(names))
for _, name := range names {
var rec DBParameterRecord
found, err := getJSON(ctx, kv, prefix+name, &rec)
if err != nil {
return nil, err
}
// A parameter reset between the listing and this read is simply gone,
// which is the answer a resolve one tick later would give too.
if found {
out[name] = rec
}
}
return out, nil
}
// ListAutomatedBackups returns the automated-backup timestamps grouped by DB instance from one bucket
// listing: the retention sweep needs every instance's set, and a Keys call per instance would cost one
// listing each.
-9
View File
@@ -18,9 +18,6 @@ func TestKeyPaths(t *testing.T) {
t.Parallel()
assert.Equal(t, "db-instances/orders-db", DBInstanceKey("orders-db"))
assert.Equal(t, "db-snapshots/orders-db-final", DBSnapshotKey("orders-db-final"))
assert.Equal(t, "db-parameter-groups/pg16/meta", DBParameterGroupMetaKey("pg16"))
assert.Equal(t, "db-parameter-groups/pg16/params/shared_buffers",
DBParameterGroupParamKey("pg16", "shared_buffers"))
assert.Equal(t, "backups/orders-db/automated/20260724T170000Z",
AutomatedBackupKey("orders-db", "20260724T170000Z"))
assert.Equal(t, "retained-volumes/vol-abc123", RetainedVolumeKey("vol-abc123"))
@@ -33,15 +30,9 @@ func TestKeyPathsSitUnderTheirPrefix(t *testing.T) {
t.Parallel()
assert.True(t, strings.HasPrefix(DBInstanceKey("x"), DBInstancesPrefix()))
assert.True(t, strings.HasPrefix(DBSnapshotKey("x"), DBSnapshotsPrefix()))
assert.True(t, strings.HasPrefix(DBParameterGroupMetaKey("x"), DBParameterGroupsPrefix()))
assert.True(t, strings.HasPrefix(DBParameterGroupParamKey("x", "p"), DBParameterGroupParamsPrefix("x")))
assert.True(t, strings.HasPrefix(AutomatedBackupKey("x", "t"), AutomatedBackupsPrefix("x")))
assert.True(t, strings.HasPrefix(RetainedVolumeKey("v"), RetainedVolumesPrefix()))
assert.True(t, strings.HasPrefix(InstanceIndexKey("i"), InstanceIndexPrefix()))
// A parameter group's values must not be reachable by listing groups' meta
// records, or DescribeDBParameterGroups would report one group per parameter.
assert.False(t, strings.HasPrefix(DBParameterGroupParamKey("x", "p"), DBParameterGroupMetaKey("x")))
}
func TestNewStore_NilConn(t *testing.T) {
+4 -2
View File
@@ -10,6 +10,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/rds"
"github.com/mulgadc/spinifex/spinifex/domains/rds/parametergroup"
"github.com/mulgadc/spinifex/spinifex/domains/rds/subnetgroup"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
@@ -50,6 +51,7 @@ type taggableResource struct {
// record, so a tag written to it would have nowhere to live and would read back
// as absent on the next apply.
var _ TaggedRecord = (*subnetgroup.Record)(nil)
var _ TaggedRecord = (*parametergroup.Record)(nil)
var taggableResources = map[ResourceKind]taggableResource{
ResourceKindDBInstance: {
@@ -68,8 +70,8 @@ var taggableResources = map[ResourceKind]taggableResource{
notFound: awserrors.ErrorDBSubnetGroupNotFound,
},
ResourceKindDBParameterGroup: {
key: DBParameterGroupMetaKey,
newRecord: func() TaggedRecord { return &DBParameterGroupRecord{} },
key: parametergroup.MetaKey,
newRecord: func() TaggedRecord { return &parametergroup.Record{} },
notFound: awserrors.ErrorDBParameterGroupNotFound,
},
}