feat: add Instance endpoint value object
This commit is contained in:
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user