From 87ec7896b5effd9b483fb6dd6ea43b6470fea3cd Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Mon, 14 Sep 2026 15:54:01 +0000 Subject: [PATCH] feat: add observed Instance extension support checks --- internal/domain/instance/extensions.go | 84 +++++++++++++++ internal/domain/instance/extensions_test.go | 107 ++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 internal/domain/instance/extensions.go create mode 100644 internal/domain/instance/extensions_test.go diff --git a/internal/domain/instance/extensions.go b/internal/domain/instance/extensions.go new file mode 100644 index 0000000..7428130 --- /dev/null +++ b/internal/domain/instance/extensions.go @@ -0,0 +1,84 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package instance + +import "slices" + +// ExtensionSet is an immutable set of exact names. Zero represents the empty set. +// It does not impose identifier syntax or claim that a server supports any name. +type ExtensionSet struct { + names []string +} + +func NewExtensionSet(names []string) ExtensionSet { + copied := slices.Clone(names) + slices.Sort(copied) + return ExtensionSet{names: slices.Compact(copied)} +} + +// Names returns a sorted, deduplicated copy. +func (s ExtensionSet) Names() []string { return slices.Clone(s.names) } + +type ExtensionDecision string + +const ( + ExtensionsAccepted ExtensionDecision = "Accepted" + ExtensionsUnsupported ExtensionDecision = "ExtensionsUnsupported" + ExtensionSupportUnobserved ExtensionDecision = "ExtensionSupportUnobserved" +) + +// ExtensionCheck reports support only, not readiness or permission to install. +// Unsupported is a detached, sorted list and is populated only for known support. +type ExtensionCheck struct { + Decision ExtensionDecision + Unsupported []string +} + +// ExtensionSupport is the extension-list component of an Instance observation. +// Zero means unobserved, not an observed empty list. Target/revision binding and +// invalidation belong to the containing Instance observation, not this set value. +type ExtensionSupport struct { + observed bool + available ExtensionSet +} + +// ObserveExtensionSupport records a successfully read list, including an empty one. +// A failed query must not call this constructor with an empty list: the application +// must propagate the dependency failure and leave support unobserved. +func ObserveExtensionSupport(available []string) ExtensionSupport { + return ExtensionSupport{observed: true, available: NewExtensionSet(available)} +} + +// Check performs no IO and cannot install or remove extensions. +func (s ExtensionSupport) Check(requested ExtensionSet) ExtensionCheck { + if len(requested.names) == 0 { + return ExtensionCheck{Decision: ExtensionsAccepted} + } + if !s.observed { + return ExtensionCheck{Decision: ExtensionSupportUnobserved} + } + var unsupported []string + for _, name := range requested.names { + if _, found := slices.BinarySearch(s.available.names, name); !found { + unsupported = append(unsupported, name) + } + } + if len(unsupported) != 0 { + return ExtensionCheck{Decision: ExtensionsUnsupported, Unsupported: unsupported} + } + return ExtensionCheck{Decision: ExtensionsAccepted} +} diff --git a/internal/domain/instance/extensions_test.go b/internal/domain/instance/extensions_test.go new file mode 100644 index 0000000..7cb0baa --- /dev/null +++ b/internal/domain/instance/extensions_test.go @@ -0,0 +1,107 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package instance_test + +import ( + "slices" + "testing" + + "git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/domain/instance" +) + +const ( + testUUID = "uuid-ossp" + testTrigram = "pg_trgm" + testVector = "vector" + testChanged = "changed" +) + +// Acceptance: docs/domain-instance.md, extension support is based on observations, +// not a name regexp or an administrator allowlist. +func TestExtensionSupportDecisions(t *testing.T) { + available := instance.ObserveExtensionSupport([]string{testTrigram, testUUID}) + cases := []struct { + name string + support instance.ExtensionSupport + requested []string + want instance.ExtensionDecision + unsupported []string + }{ + {"unobserved", instance.ExtensionSupport{}, []string{testTrigram}, instance.ExtensionSupportUnobserved, nil}, + {"observed empty", instance.ObserveExtensionSupport(nil), []string{testTrigram}, instance.ExtensionsUnsupported, []string{testTrigram}}, + {"empty request", instance.ExtensionSupport{}, nil, instance.ExtensionsAccepted, nil}, + {"supported", available, []string{testUUID, testTrigram, testTrigram}, instance.ExtensionsAccepted, nil}, + {"unsupported", available, []string{testVector, "hstore", testVector, testTrigram}, + instance.ExtensionsUnsupported, []string{"hstore", testVector}}, + {"exact names", available, []string{"PG_TRGM"}, instance.ExtensionsUnsupported, []string{"PG_TRGM"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result := tc.support.Check(instance.NewExtensionSet(tc.requested)) + if result.Decision != tc.want || !slices.Equal(result.Unsupported, tc.unsupported) { + t.Fatalf("Check() = %v, want %v / %v", result, tc.want, tc.unsupported) + } + }) + } +} + +func TestExtensionSetCopiesAndCanonicalizesNames(t *testing.T) { + input := []string{testUUID, testTrigram, testUUID} + set := instance.NewExtensionSet(input) + input[0] = testChanged + names := set.Names() + want := []string{testTrigram, testUUID} + if !slices.Equal(names, want) { + t.Fatalf("Names() = %v, want %v", names, want) + } + names[0] = testChanged + if !slices.Equal(set.Names(), want) { + t.Fatal("returned slice mutated set") + } + if len((instance.ExtensionSet{}).Names()) != 0 { + t.Fatal("zero set must be empty") + } + // Names are preserved exactly; actual server support, not a local regexp, is decisive. + unusual := []string{"Vendor.Extension", testUUID} + if result := instance.ObserveExtensionSupport(unusual).Check(instance.NewExtensionSet(unusual)); result.Decision != instance.ExtensionsAccepted { + t.Fatal("imposed a local name restriction") + } +} + +func TestExtensionSupportCopiesObservationAndResults(t *testing.T) { + input := []string{testTrigram} + support := instance.ObserveExtensionSupport(input) + input[0] = testVector + requested := instance.NewExtensionSet([]string{testTrigram, testVector}) + result := support.Check(requested) + if !slices.Equal(result.Unsupported, []string{testVector}) { + t.Fatal("input mutation changed observation") + } + result.Unsupported[0] = testChanged + again := support.Check(requested) + if !slices.Equal(again.Unsupported, []string{testVector}) { + t.Fatal("result mutation changed subsequent decision") + } + // Replacing an observation does not mutate the old value or produce uninstall actions. + empty := instance.ObserveExtensionSupport(nil) + if empty.Check(requested).Decision != instance.ExtensionsUnsupported { + t.Fatal("empty observation ignored") + } + if support.Check(instance.NewExtensionSet([]string{testTrigram})).Decision != instance.ExtensionsAccepted { + t.Fatal("new observation mutated old value") + } +} -- 2.54.0