From 5521d5f98d51819acd0b75bcf66bb3663bb4c233 Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Mon, 14 Sep 2026 10:18:42 +0000 Subject: [PATCH] feat: add Instance identity and revision value objects --- .../domain/instance/credential_reference.go | 6 +- internal/domain/instance/identity.go | 73 +++++++++++++ internal/domain/instance/identity_test.go | 102 ++++++++++++++++++ 3 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 internal/domain/instance/identity.go create mode 100644 internal/domain/instance/identity_test.go diff --git a/internal/domain/instance/credential_reference.go b/internal/domain/instance/credential_reference.go index 780edc2..00351a3 100644 --- a/internal/domain/instance/credential_reference.go +++ b/internal/domain/instance/credential_reference.go @@ -36,8 +36,8 @@ type CredentialReference struct { values CredentialReferenceValues } -// Secret names follow the Kubernetes DNS subdomain syntax and 253-character limit. -var secretName = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`) +// Instance and Secret names share the DNS subdomain syntax and 253-character limit. +var dnsSubdomainName = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`) func NewCredentialReference(values CredentialReferenceValues) (CredentialReference, error) { reference := CredentialReference{values: values} @@ -54,7 +54,7 @@ func (r CredentialReference) Values() CredentialReferenceValues { return r.value // Checking that the referenced Secret contains nonempty credentials is an application // responsibility. Errors omit input values and no implicit defaults are applied. func (r CredentialReference) Validate() error { - if len(r.values.Name) > 253 || !secretName.MatchString(r.values.Name) { + if len(r.values.Name) > 253 || !dnsSubdomainName.MatchString(r.values.Name) { return errors.New("management Secret name must be a valid DNS subdomain of at most 253 characters") } if r.values.UsernameKey == "" { diff --git a/internal/domain/instance/identity.go b/internal/domain/instance/identity.go new file mode 100644 index 0000000..c09cb79 --- /dev/null +++ b/internal/domain/instance/identity.go @@ -0,0 +1,73 @@ +/* +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 "errors" + +// Identity identifies one registration, not a physical PostgreSQL server. +// UID is opaque; a recreated resource with the same name has a different identity. +// Its zero value is invalid and must be rejected when constructing an aggregate. +type Identity struct { + uid string + name string +} + +func NewIdentity(uid, name string) (Identity, error) { + identity := Identity{uid: uid, name: name} + if err := identity.Validate(); err != nil { + return Identity{}, err + } + return identity, nil +} + +func (i Identity) UID() string { return i.uid } +func (i Identity) Name() string { return i.name } + +// Validate checks registration values without looking up any external identity. +func (i Identity) Validate() error { + if i.uid == "" { + return errors.New("instance UID is required") + } + if len(i.name) > 253 || !dnsSubdomainName.MatchString(i.name) { + return errors.New("instance name must be a valid DNS subdomain of at most 253 characters") + } + return nil +} + +// Revision is a positive configuration generation, separate from Identity. +// Zero is invalid for desired configuration; an unobserved status generation of +// zero must be represented separately when the aggregate is implemented. +type Revision struct { + value int64 +} + +func NewRevision(value int64) (Revision, error) { + revision := Revision{value: value} + if err := revision.Validate(); err != nil { + return Revision{}, err + } + return revision, nil +} + +func (r Revision) Value() int64 { return r.value } + +func (r Revision) Validate() error { + if r.value <= 0 { + return errors.New("instance revision must be positive") + } + return nil +} diff --git a/internal/domain/instance/identity_test.go b/internal/domain/instance/identity_test.go new file mode 100644 index 0000000..ac2dd26 --- /dev/null +++ b/internal/domain/instance/identity_test.go @@ -0,0 +1,102 @@ +/* +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 ( + "math" + "strings" + "testing" + + "git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/domain/instance" +) + +// Acceptance: docs/domain-instance.md ยง2: registration identity is UID + name, +// independent of the configuration revision or physical PostgreSQL server. +func TestIdentityDistinguishesRecreatedInstances(t *testing.T) { + first, err := instance.NewIdentity("opaque-uid-1", "shared") + if err != nil { + t.Fatal(err) + } + same, err := instance.NewIdentity("opaque-uid-1", "shared") + if err != nil { + t.Fatal(err) + } + recreated, err := instance.NewIdentity("opaque-uid-2", "shared") + if err != nil { + t.Fatal(err) + } + if first != same || first == recreated { + t.Fatal("identity must distinguish same-name registrations by UID") + } + if first.UID() != "opaque-uid-1" || first.Name() != "shared" { + t.Fatal("identity changed declared values") + } + if err := first.Validate(); err != nil { + t.Fatal(err) + } +} + +func TestIdentityValidation(t *testing.T) { + for _, name := range []string{"", "Shared", "shared_name", "ns/shared", "-shared", "pg..shared", strings.Repeat("a", 254)} { + identity, err := instance.NewIdentity("uid", name) + if err == nil || identity != (instance.Identity{}) { + t.Fatal("invalid name accepted or partial identity returned") + } + } + if _, err := instance.NewIdentity("", "shared"); err == nil { + t.Fatal("empty UID accepted") + } + if err := (instance.Identity{}).Validate(); err == nil { + t.Fatal("zero identity accepted") + } + for _, name := range []string{"a", "1", "pg.shared-1", strings.Repeat("a", 253)} { + if _, err := instance.NewIdentity("opaque-not-a-uuid", name); err != nil { + t.Fatal(err) + } + } +} + +func TestRevisionRequiresPositiveValue(t *testing.T) { + for _, value := range []int64{math.MinInt64, -1, 0} { + revision, err := instance.NewRevision(value) + if err == nil || revision != (instance.Revision{}) { + t.Fatal("invalid revision accepted or partial value returned") + } + } + for _, value := range []int64{1, 2, math.MaxInt64} { + revision, err := instance.NewRevision(value) + if err != nil { + t.Fatal(err) + } + if revision.Value() != value { + t.Fatal("revision changed declared value") + } + if err := revision.Validate(); err != nil { + t.Fatal(err) + } + same, err := instance.NewRevision(value) + if err != nil { + t.Fatal(err) + } + if revision != same { + t.Fatal("identical revisions must compare equal") + } + } + if err := (instance.Revision{}).Validate(); err == nil { + t.Fatal("zero revision accepted") + } +}