Compare commits

Author SHA1 Message Date
panxiao81 ed6fd7bb85 Merge pull request 'feat: Instance Identity 与 Revision 值对象及测试' (#9) from feature/instance-identity-values into main
E2E Tests / Run on Ubuntu (push) Failing after 46s
Tests / Run on Ubuntu (push) Successful in 6m2s
Lint / Run on Ubuntu (push) Successful in 7m3s
Reviewed-on: #9
2026-09-14 10:43:53 +00:00
panxiao81 5521d5f98d feat: add Instance identity and revision value objects
E2E Tests / Run on Ubuntu (pull_request) Failing after 30s
Tests / Run on Ubuntu (pull_request) Successful in 5m11s
Lint / Run on Ubuntu (pull_request) Successful in 7m32s
2026-09-14 10:18:42 +00:00
panxiao81 a0f62b1e01 Merge pull request 'feat: Instance CredentialReference 值对象与校验测试' (#8) from feature/instance-credential-reference into main
E2E Tests / Run on Ubuntu (push) Failing after 1m4s
Tests / Run on Ubuntu (push) Successful in 7m6s
Lint / Run on Ubuntu (push) Successful in 8m19s
Reviewed-on: #8
2026-09-13 15:55:31 +00:00
panxiao81 536d6e665f feat: add Instance credential reference value object
E2E Tests / Run on Ubuntu (pull_request) Failing after 41s
Tests / Run on Ubuntu (pull_request) Successful in 6m11s
Lint / Run on Ubuntu (pull_request) Successful in 8m31s
2026-09-13 15:52:17 +00:00
panxiao81 6db3f756b2 Merge pull request 'feat: Instance Endpoint 值对象与纯校验测试' (#7) from feature/instance-endpoint-values into main
E2E Tests / Run on Ubuntu (push) Failing after 59s
Tests / Run on Ubuntu (push) Successful in 5m4s
Lint / Run on Ubuntu (push) Successful in 5m34s
Reviewed-on: #7
2026-09-13 15:44:31 +00:00
panxiao81 bf7b8e4066 feat: add Instance endpoint value object
E2E Tests / Run on Ubuntu (pull_request) Failing after 1m1s
Tests / Run on Ubuntu (pull_request) Successful in 4m28s
Lint / Run on Ubuntu (pull_request) Successful in 5m10s
2026-09-13 15:38:07 +00:00
panxiao81 012efc1581 Merge pull request 'Instance 阶段性基线:Ready 初版与领域设计' (#6) from feature/openbao-adapter into main
E2E Tests / Run on Ubuntu (push) Failing after 1m0s
Tests / Run on Ubuntu (push) Successful in 7m3s
Lint / Run on Ubuntu (push) Successful in 8m8s
2026-09-13 15:26:35 +00:00
6 changed files with 568 additions and 0 deletions
@@ -0,0 +1,67 @@
/*
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"
"regexp"
)
// CredentialReferenceValues contains effective field mappings, not secret data.
// The application supplies defaults and fixes the namespace to the controller's.
// Namespace and provider-specific paths are deliberately not selectable here.
type CredentialReferenceValues struct {
Name string
UsernameKey string
PasswordKey string
}
// CredentialReference is an immutable reference to a management Secret.
// Its zero value is invalid; aggregate construction must Validate incoming values.
type CredentialReference struct {
values CredentialReferenceValues
}
// 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}
if err := reference.Validate(); err != nil {
return CredentialReference{}, err
}
return reference, nil
}
// Values returns a copy of the reference, never secret contents.
func (r CredentialReference) Values() CredentialReferenceValues { return r.values }
// Validate enforces reference invariants without accessing Kubernetes or OpenBao.
// 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 || !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 == "" {
return errors.New("management Secret username field is required")
}
if r.values.PasswordKey == "" {
return errors.New("management Secret password field is required")
}
return nil
}
@@ -0,0 +1,119 @@
/*
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 (
"strings"
"testing"
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/domain/instance"
)
func validCredentialReference() instance.CredentialReferenceValues {
return instance.CredentialReferenceValues{
Name: "shared-postgresql-admin", UsernameKey: "username", PasswordKey: "password",
}
}
// Acceptance: docs/domain-instance.md §2. References carry names, never credentials or IO.
func TestCredentialReferenceRejectsInvalidValues(t *testing.T) {
cases := []struct {
name string
change func(*instance.CredentialReferenceValues)
}{
{"empty name", func(v *instance.CredentialReferenceValues) { v.Name = "" }},
{"uppercase", func(v *instance.CredentialReferenceValues) { v.Name = "Admin" }},
{"underscore", func(v *instance.CredentialReferenceValues) { v.Name = "pg_admin" }},
{"leading hyphen", func(v *instance.CredentialReferenceValues) { v.Name = "-admin" }},
{"trailing hyphen", func(v *instance.CredentialReferenceValues) { v.Name = "admin-" }},
{"empty label", func(v *instance.CredentialReferenceValues) { v.Name = "pg..admin" }},
{"trailing dot", func(v *instance.CredentialReferenceValues) { v.Name = "pg." }},
{"namespace or path", func(v *instance.CredentialReferenceValues) { v.Name = "system/admin" }},
{"whitespace", func(v *instance.CredentialReferenceValues) { v.Name = " admin" }},
{"too long", func(v *instance.CredentialReferenceValues) { v.Name = strings.Repeat("a", 254) }},
{"empty username key", func(v *instance.CredentialReferenceValues) { v.UsernameKey = "" }},
{"empty password key", func(v *instance.CredentialReferenceValues) { v.PasswordKey = "" }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
values := validCredentialReference()
tc.change(&values)
reference, err := instance.NewCredentialReference(values)
if err == nil {
t.Fatal("invalid credential reference accepted")
}
if reference != (instance.CredentialReference{}) {
t.Fatal("constructor returned a partial reference on failure")
}
})
}
}
func TestCredentialReferencePreservesExplicitValues(t *testing.T) {
for _, name := range []string{"a", "1", "pg.admin-1", strings.Repeat("a", 253)} {
values := validCredentialReference()
values.Name = name
values.UsernameKey = "PG_USER"
values.PasswordKey = "pg.password"
reference, err := instance.NewCredentialReference(values)
if err != nil {
t.Fatal(err)
}
if reference.Values() != values {
t.Fatal("constructor changed the explicit field mapping")
}
if err := reference.Validate(); err != nil {
t.Fatal(err)
}
}
}
func TestCredentialReferenceIsAnImmutableComparableValue(t *testing.T) {
values := validCredentialReference()
reference, err := instance.NewCredentialReference(values)
if err != nil {
t.Fatal(err)
}
same, err := instance.NewCredentialReference(values)
if err != nil {
t.Fatal(err)
}
if reference != same {
t.Fatal("identical references must compare equal")
}
values.Name = "different"
snapshot := reference.Values()
snapshot.PasswordKey = "different-key"
if reference.Values() != validCredentialReference() {
t.Fatal("caller mutated reference through a copy")
}
if err := (instance.CredentialReference{}).Validate(); err == nil {
t.Fatal("zero reference must be invalid")
}
}
func TestCredentialReferenceErrorOmitsInput(t *testing.T) {
values := validCredentialReference()
values.Name = "canary-sensitive/input"
_, err := instance.NewCredentialReference(values)
if err == nil {
t.Fatal("invalid reference accepted")
}
if strings.Contains(err.Error(), "canary") {
t.Fatal("error included input")
}
}
+89
View File
@@ -0,0 +1,89 @@
/*
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 contains the pure domain model of a registered PostgreSQL instance.
// It does not depend on Kubernetes types, database drivers or credential providers.
package instance
import (
"errors"
"net/netip"
"regexp"
)
// TLSMode is an explicit transport policy, not a driver-specific default.
type TLSMode string
const (
TLSDisable TLSMode = "disable"
TLSRequire TLSMode = "require"
TLSVerifyCA TLSMode = "verify-ca"
TLSVerifyFull TLSMode = "verify-full"
)
// EndpointValues carries explicit, effective values across the application boundary.
// Defaults are supplied by the API/application mapping, never silently by the domain.
type EndpointValues struct {
Host string
HostAddr string
Port int
ManagementDatabase string
TLSMode TLSMode
}
// Endpoint is an immutable connection target. Equality compares its declared values,
// not physical server identity. Its zero value is invalid; aggregate construction
// must Validate incoming endpoints, even if callers bypass NewEndpoint.
type Endpoint struct {
values EndpointValues
}
var identifier = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`)
func NewEndpoint(values EndpointValues) (Endpoint, error) {
endpoint := Endpoint{values: values}
if err := endpoint.Validate(); err != nil {
return Endpoint{}, err
}
return endpoint, nil
}
// Values returns a copy, without exposing mutable state.
func (e Endpoint) Values() EndpointValues { return e.values }
// Validate checks local invariants only; it does not resolve DNS or perform IO.
// Errors intentionally omit input values.
func (e Endpoint) Validate() error {
if e.values.Host == "" {
return errors.New("endpoint host is required")
}
address, err := netip.ParseAddr(e.values.HostAddr)
if err != nil || address.Zone() != "" {
return errors.New("endpoint host address must be an IPv4 or IPv6 address")
}
if e.values.Port < 1 || e.values.Port > 65535 {
return errors.New("endpoint port must be between 1 and 65535")
}
if !identifier.MatchString(e.values.ManagementDatabase) {
return errors.New("endpoint management database must be a valid PostgreSQL identifier")
}
switch e.values.TLSMode {
case TLSDisable, TLSRequire, TLSVerifyCA, TLSVerifyFull:
return nil
default:
return errors.New("endpoint TLS mode must be explicitly supported")
}
}
+118
View File
@@ -0,0 +1,118 @@
/*
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 (
"strings"
"testing"
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/domain/instance"
)
func validEndpoint() instance.EndpointValues {
return instance.EndpointValues{
Host: "postgres.home.arpa", HostAddr: "192.0.2.10", Port: 5432,
ManagementDatabase: "postgres", TLSMode: instance.TLSVerifyFull,
}
}
// Acceptance: docs/domain-instance.md §2, explicit values and no implicit TLS downgrade.
func TestEndpointRejectsInvalidValues(t *testing.T) {
cases := []struct {
name string
change func(*instance.EndpointValues)
}{
{"empty host", func(v *instance.EndpointValues) { v.Host = "" }},
{"missing address", func(v *instance.EndpointValues) { v.HostAddr = "" }},
{"DNS instead of IP", func(v *instance.EndpointValues) { v.HostAddr = "postgres.home.arpa" }},
{"invalid IP", func(v *instance.EndpointValues) { v.HostAddr = "192.0.2.999" }},
{"address with port", func(v *instance.EndpointValues) { v.HostAddr = "192.0.2.10:5432" }},
{"scoped address", func(v *instance.EndpointValues) { v.HostAddr = "fe80::1%eth0" }},
{"zero port", func(v *instance.EndpointValues) { v.Port = 0 }},
{"negative port", func(v *instance.EndpointValues) { v.Port = -1 }},
{"large port", func(v *instance.EndpointValues) { v.Port = 65536 }},
{"empty database", func(v *instance.EndpointValues) { v.ManagementDatabase = "" }},
{"uppercase database", func(v *instance.EndpointValues) { v.ManagementDatabase = "Postgres" }},
{"leading digit", func(v *instance.EndpointValues) { v.ManagementDatabase = "1postgres" }},
{"punctuation", func(v *instance.EndpointValues) { v.ManagementDatabase = "post-gres" }},
{"NUL", func(v *instance.EndpointValues) { v.ManagementDatabase = "post\x00gres" }},
{"long identifier", func(v *instance.EndpointValues) { v.ManagementDatabase = strings.Repeat("a", 64) }},
{"missing TLS mode", func(v *instance.EndpointValues) { v.TLSMode = "" }},
{"unsupported TLS mode", func(v *instance.EndpointValues) { v.TLSMode = "prefer" }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
values := validEndpoint()
tc.change(&values)
endpoint, err := instance.NewEndpoint(values)
if err == nil {
t.Fatal("invalid endpoint accepted")
}
if endpoint != (instance.Endpoint{}) {
t.Fatal("constructor returned a partial endpoint on failure")
}
})
}
}
func TestEndpointPreservesValidValues(t *testing.T) {
for _, mode := range []instance.TLSMode{
instance.TLSDisable, instance.TLSRequire, instance.TLSVerifyCA, instance.TLSVerifyFull,
} {
for _, address := range []string{"192.0.2.10", "2001:db8::10"} {
for _, port := range []int{1, 65535} {
values := validEndpoint()
values.TLSMode, values.HostAddr, values.Port = mode, address, port
values.ManagementDatabase = "a" + strings.Repeat("_", 62)
endpoint, err := instance.NewEndpoint(values)
if err != nil {
t.Fatal(err)
}
if endpoint.Values() != values {
t.Fatal("constructor changed explicit values")
}
if err := endpoint.Validate(); err != nil {
t.Fatal(err)
}
}
}
}
}
func TestEndpointIsAnImmutableComparableValue(t *testing.T) {
values := validEndpoint()
endpoint, err := instance.NewEndpoint(values)
if err != nil {
t.Fatal(err)
}
same, err := instance.NewEndpoint(values)
if err != nil {
t.Fatal(err)
}
if endpoint != same {
t.Fatal("identical endpoint values must compare equal")
}
values.Host = "changed.example"
snapshot := endpoint.Values()
snapshot.Host = values.Host
if endpoint.Values().Host == snapshot.Host {
t.Fatal("caller mutated endpoint through a copy")
}
if err := (instance.Endpoint{}).Validate(); err == nil {
t.Fatal("zero endpoint must not be valid")
}
}
+73
View File
@@ -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
}
+102
View File
@@ -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")
}
}