diff --git a/internal/domain/instance/endpoint.go b/internal/domain/instance/endpoint.go new file mode 100644 index 0000000..15648e2 --- /dev/null +++ b/internal/domain/instance/endpoint.go @@ -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") + } +} diff --git a/internal/domain/instance/endpoint_test.go b/internal/domain/instance/endpoint_test.go new file mode 100644 index 0000000..d24ea82 --- /dev/null +++ b/internal/domain/instance/endpoint_test.go @@ -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") + } +}