From d584258f558fc5c0afeb0421fc2c428e48213150 Mon Sep 17 00:00:00 2001 From: Gabrielle Poncey Date: Tue, 8 Sep 2026 10:59:17 -0700 Subject: [PATCH 1/4] fix: reject Spock major version change on update Major-version gaurd now rejects Spock major version changes on an existing database. Docs now note this change. --- docs/using/image-management.md | 5 ++--- server/internal/database/service.go | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/using/image-management.md b/docs/using/image-management.md index 6b2c09b6..b4194387 100644 --- a/docs/using/image-management.md +++ b/docs/using/image-management.md @@ -320,9 +320,8 @@ pinning a specific build. See This preview supports only creating a new database on Spock 6. It does not support upgrading an existing database from Spock 5.x to - Spock 6. The Control Plane does not validate or block a - `spock_version` change on an existing database's spec, but doing so - is unsupported and can break replication, since a Spock 6 + Spock 6. The Control Plane rejects a spec update that changes the + Spock major version on an existing database, since a Spock 6 subscription cannot sync from a Spock 5.x peer. !!! warning diff --git a/server/internal/database/service.go b/server/internal/database/service.go index 29d10951..a55479aa 100644 --- a/server/internal/database/service.go +++ b/server/internal/database/service.go @@ -1087,6 +1087,21 @@ func majorVersionChanged(old, new *ds.PgEdgeVersion) error { if oldPgMajor != newPgMajor { return fmt.Errorf("major version changed from %d to %d", oldPgMajor, newPgMajor) } + + oldSpockMajor, ok := old.SpockVersion.Major() + if !ok { + return errors.New("current spock version is missing its major component") + } + newSpockMajor, ok := new.SpockVersion.Major() + if !ok { + return errors.New("updated spock version is missing its major component") + } + if oldSpockMajor != newSpockMajor { + return fmt.Errorf( + "spock major version changed from %d to %d: upgrading the spock major version on an existing database is not supported", + oldSpockMajor, newSpockMajor, + ) + } return nil } From 76a0cd8fc6408f4c76e5ad685f4ae7d73586363f Mon Sep 17 00:00:00 2001 From: Gabrielle Poncey Date: Wed, 9 Sep 2026 07:16:29 -0700 Subject: [PATCH 2/4] fix: phrasing --- server/internal/database/service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/internal/database/service.go b/server/internal/database/service.go index a55479aa..67c33f18 100644 --- a/server/internal/database/service.go +++ b/server/internal/database/service.go @@ -1098,7 +1098,7 @@ func majorVersionChanged(old, new *ds.PgEdgeVersion) error { } if oldSpockMajor != newSpockMajor { return fmt.Errorf( - "spock major version changed from %d to %d: upgrading the spock major version on an existing database is not supported", + "spock major version changed from %d to %d: changing the spock major version on an existing database is not supported", oldSpockMajor, newSpockMajor, ) } From dcdac27547dd39410b2e34ee416ac9d8e555553c Mon Sep 17 00:00:00 2001 From: Gabrielle Poncey Date: Tue, 15 Sep 2026 12:15:01 -0700 Subject: [PATCH 3/4] fix: catch Spock major change on host reassignment Move Spock major-version check into a single database-wide check in order to catch major version change being submitted alongside a HostID change. --- server/internal/database/service.go | 67 +++++++++++++++++------ server/internal/database/service_test.go | 68 ++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 17 deletions(-) diff --git a/server/internal/database/service.go b/server/internal/database/service.go index 67c33f18..8814ed12 100644 --- a/server/internal/database/service.go +++ b/server/internal/database/service.go @@ -1027,6 +1027,16 @@ func ValidateChangedSpec(current, updated *Spec) error { errs = append(errs, errors.New("database name cannot be changed")) } + // Spock version is a database-wide property (every node inherits + // Spec.SpockVersion; there's no per-node override), so it's checked once + // here rather than per-instance. Checking it per-instance would miss a + // major version change submitted alongside a HostIDs change, since that + // changes the instance ID and would no longer match between current and + // updated. + if err := spockMajorVersionChanged(current.SpockVersion, updated.SpockVersion); err != nil { + errs = append(errs, err) + } + currentInstances, err := instancesByID(current) if err != nil { errs = append(errs, fmt.Errorf("failed to compute instances from current spec: %w", err)) @@ -1076,35 +1086,58 @@ func majorVersionChanged(old, new *ds.PgEdgeVersion) error { if old == nil || new == nil { return errors.New("expected both current and updated versions to be defined") } - oldPgMajor, ok := old.PostgresVersion.Major() - if !ok { - return errors.New("current postgres version is missing its major component") - } - newPgMajor, ok := new.PostgresVersion.Major() - if !ok { - return errors.New("updated postgres version is missing its major component") + oldMajor, newMajor, err := extractMajors("postgres", old.PostgresVersion, new.PostgresVersion) + if err != nil { + return err } - if oldPgMajor != newPgMajor { - return fmt.Errorf("major version changed from %d to %d", oldPgMajor, newPgMajor) + if oldMajor != newMajor { + return fmt.Errorf("major version changed from %d to %d", oldMajor, newMajor) } + return nil +} - oldSpockMajor, ok := old.SpockVersion.Major() - if !ok { - return errors.New("current spock version is missing its major component") +// spockMajorVersionChanged rejects a Spock major version change on an +// existing database. A Spock N subscription cannot sync from a Spock N-1 +// peer, so this must be caught here rather than left to fail at replication +// time. It does not apply to a dedicated Spock upgrade workflow (PLAT-720), +// which is expected to have its own validation. +func spockMajorVersionChanged(current, updated string) error { + oldVersion, err := ds.ParseVersion(current) + if err != nil { + return fmt.Errorf("failed to parse current spock version: %w", err) } - newSpockMajor, ok := new.SpockVersion.Major() - if !ok { - return errors.New("updated spock version is missing its major component") + newVersion, err := ds.ParseVersion(updated) + if err != nil { + return fmt.Errorf("failed to parse updated spock version: %w", err) + } + oldMajor, newMajor, err := extractMajors("spock", oldVersion, newVersion) + if err != nil { + return err } - if oldSpockMajor != newSpockMajor { + if oldMajor != newMajor { return fmt.Errorf( "spock major version changed from %d to %d: changing the spock major version on an existing database is not supported", - oldSpockMajor, newSpockMajor, + oldMajor, newMajor, ) } return nil } +// extractMajors returns the major version components of old and new, using +// label to identify the version kind (e.g. "postgres", "spock") in error +// messages if either is missing its major component. +func extractMajors(label string, old, new *ds.Version) (uint64, uint64, error) { + oldMajor, ok := old.Major() + if !ok { + return 0, 0, fmt.Errorf("current %s version is missing its major component", label) + } + newMajor, ok := new.Major() + if !ok { + return 0, 0, fmt.Errorf("updated %s version is missing its major component", label) + } + return oldMajor, newMajor, nil +} + func tenantIDsMatch(a, b *string) bool { switch { case a == nil && b == nil: diff --git a/server/internal/database/service_test.go b/server/internal/database/service_test.go index 3904a98a..842c6318 100644 --- a/server/internal/database/service_test.go +++ b/server/internal/database/service_test.go @@ -192,6 +192,74 @@ func TestValidateChangedSpec(t *testing.T) { }, expectedErr: "major version changed from 17 to 18", }, + { + name: "valid spock minor version change", + current: &database.Spec{ + TenantID: utils.PointerTo("tenant-id"), + DatabaseName: "test", + PostgresVersion: "18.0", + SpockVersion: "5.0.6", + Nodes: []*database.Node{ + {Name: "n1", HostIDs: []string{"host-1"}}, + }, + }, + updated: &database.Spec{ + TenantID: utils.PointerTo("tenant-id"), + DatabaseName: "test", + PostgresVersion: "18.0", + SpockVersion: "5.0.9", + Nodes: []*database.Node{ + {Name: "n1", HostIDs: []string{"host-1"}}, + }, + }, + }, + { + name: "invalid spock major version change", + current: &database.Spec{ + TenantID: utils.PointerTo("tenant-id"), + DatabaseName: "test", + PostgresVersion: "18.0", + SpockVersion: "5", + Nodes: []*database.Node{ + {Name: "n1", HostIDs: []string{"host-1"}}, + }, + }, + updated: &database.Spec{ + TenantID: utils.PointerTo("tenant-id"), + DatabaseName: "test", + PostgresVersion: "18.0", + SpockVersion: "6", + Nodes: []*database.Node{ + {Name: "n1", HostIDs: []string{"host-1"}}, + }, + }, + expectedErr: "spock major version changed from 5 to 6", + }, + { + name: "invalid spock major version change alongside a host reassignment", + current: &database.Spec{ + TenantID: utils.PointerTo("tenant-id"), + DatabaseName: "test", + PostgresVersion: "18.0", + SpockVersion: "5", + Nodes: []*database.Node{ + {Name: "n1", HostIDs: []string{"host-1"}}, + }, + }, + updated: &database.Spec{ + TenantID: utils.PointerTo("tenant-id"), + DatabaseName: "test", + PostgresVersion: "18.0", + SpockVersion: "6", + Nodes: []*database.Node{ + // Reassigning the node to a new host changes its + // instance ID, so this must still be caught even though + // no instance ID matches between current and updated. + {Name: "n1", HostIDs: []string{"host-2"}}, + }, + }, + expectedErr: "spock major version changed from 5 to 6", + }, } { t.Run(tc.name, func(t *testing.T) { err := database.ValidateChangedSpec(tc.current, tc.updated) From c9837cdd02b86fa747bf78366179ca94f96acc37 Mon Sep 17 00:00:00 2001 From: Siva Date: Thu, 17 Sep 2026 17:37:44 +0530 Subject: [PATCH 4/4] consolidate major-version comparison logic --- server/internal/database/service.go | 49 +++++++++++------------- server/internal/database/service_test.go | 25 ++++++++++++ 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/server/internal/database/service.go b/server/internal/database/service.go index 8814ed12..90494535 100644 --- a/server/internal/database/service.go +++ b/server/internal/database/service.go @@ -1082,18 +1082,18 @@ func instancesByID(spec *Spec) (map[string]*InstanceSpec, error) { return byID, nil } +// majorVersionChanged rejects a Postgres major version change for a matched +// instance. Unlike Spock, Postgres supports a per-node major-version override +// (Node.PostgresVersion), so this intentionally only checks instances that +// persist across the update: replacing an instance via a HostIDs change +// (which produces a new instance ID) is exempt, since that's the supported +// path for moving a node to a new Postgres major version during a rolling +// upgrade. func majorVersionChanged(old, new *ds.PgEdgeVersion) error { if old == nil || new == nil { return errors.New("expected both current and updated versions to be defined") } - oldMajor, newMajor, err := extractMajors("postgres", old.PostgresVersion, new.PostgresVersion) - if err != nil { - return err - } - if oldMajor != newMajor { - return fmt.Errorf("major version changed from %d to %d", oldMajor, newMajor) - } - return nil + return checkMajorUnchanged("postgres", old.PostgresVersion, new.PostgresVersion, "") } // spockMajorVersionChanged rejects a Spock major version change on an @@ -1110,32 +1110,29 @@ func spockMajorVersionChanged(current, updated string) error { if err != nil { return fmt.Errorf("failed to parse updated spock version: %w", err) } - oldMajor, newMajor, err := extractMajors("spock", oldVersion, newVersion) - if err != nil { - return err - } - if oldMajor != newMajor { - return fmt.Errorf( - "spock major version changed from %d to %d: changing the spock major version on an existing database is not supported", - oldMajor, newMajor, - ) - } - return nil + return checkMajorUnchanged( + "spock", oldVersion, newVersion, + ": changing the spock major version on an existing database is not supported", + ) } -// extractMajors returns the major version components of old and new, using -// label to identify the version kind (e.g. "postgres", "spock") in error -// messages if either is missing its major component. -func extractMajors(label string, old, new *ds.Version) (uint64, uint64, error) { +// checkMajorUnchanged returns an error if old and new have different major +// version components. label identifies the version kind (e.g. "postgres", +// "spock") in every error message, and detail is appended verbatim to the +// mismatch error so callers can add version-kind-specific context. +func checkMajorUnchanged(label string, old, new *ds.Version, detail string) error { oldMajor, ok := old.Major() if !ok { - return 0, 0, fmt.Errorf("current %s version is missing its major component", label) + return fmt.Errorf("current %s version is missing its major component", label) } newMajor, ok := new.Major() if !ok { - return 0, 0, fmt.Errorf("updated %s version is missing its major component", label) + return fmt.Errorf("updated %s version is missing its major component", label) + } + if oldMajor != newMajor { + return fmt.Errorf("%s major version changed from %d to %d%s", label, oldMajor, newMajor, detail) } - return oldMajor, newMajor, nil + return nil } func tenantIDsMatch(a, b *string) bool { diff --git a/server/internal/database/service_test.go b/server/internal/database/service_test.go index 842c6318..640663e3 100644 --- a/server/internal/database/service_test.go +++ b/server/internal/database/service_test.go @@ -192,6 +192,31 @@ func TestValidateChangedSpec(t *testing.T) { }, expectedErr: "major version changed from 17 to 18", }, + { + name: "valid postgres major version change alongside a host reassignment", + current: &database.Spec{ + TenantID: utils.PointerTo("tenant-id"), + DatabaseName: "test", + PostgresVersion: "17.6", + SpockVersion: "5", + Nodes: []*database.Node{ + {Name: "n1", HostIDs: []string{"host-1"}}, + }, + }, + updated: &database.Spec{ + TenantID: utils.PointerTo("tenant-id"), + DatabaseName: "test", + PostgresVersion: "18.0", + SpockVersion: "5", + Nodes: []*database.Node{ + // Unlike Spock, moving a node to a new host is the + // supported way to bump its Postgres major version + // during a rolling upgrade, so this is intentionally + // exempt from the major-version check. + {Name: "n1", HostIDs: []string{"host-2"}}, + }, + }, + }, { name: "valid spock minor version change", current: &database.Spec{