feat(model): нейтральная модель Record с нормализацией и Equal
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build:
|
||||||
|
go build ./...
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RecordType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
A RecordType = "A"
|
||||||
|
AAAA RecordType = "AAAA"
|
||||||
|
CNAME RecordType = "CNAME"
|
||||||
|
MX RecordType = "MX"
|
||||||
|
TXT RecordType = "TXT"
|
||||||
|
SRV RecordType = "SRV"
|
||||||
|
NS RecordType = "NS"
|
||||||
|
SOA RecordType = "SOA"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Managed reports whether the type participates in diff+apply.
|
||||||
|
// NS and SOA are read-only.
|
||||||
|
func (t RecordType) Managed() bool {
|
||||||
|
switch t {
|
||||||
|
case A, AAAA, CNAME, MX, TXT, SRV:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record is the provider-neutral representation of a DNS RRset.
|
||||||
|
// For MX the value is "<priority> <target>"; for SRV it is
|
||||||
|
// "<priority> <weight> <port> <target>". Values is an unordered set.
|
||||||
|
type Record struct {
|
||||||
|
Type RecordType
|
||||||
|
Name string
|
||||||
|
TTL int
|
||||||
|
Values []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key uniquely identifies an RRset within a zone.
|
||||||
|
func (r Record) Key() string {
|
||||||
|
return string(r.Type) + " " + normalizeName(r.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeName(name string) string {
|
||||||
|
n := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if n != "" && !strings.HasSuffix(n, ".") {
|
||||||
|
n += "."
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeValue canonicalizes a single RR value for comparison.
|
||||||
|
func normalizeValue(t RecordType, content string) string {
|
||||||
|
c := strings.Join(strings.Fields(content), " ") // collapse whitespace
|
||||||
|
switch t {
|
||||||
|
case TXT:
|
||||||
|
return c // case-sensitive — keep as is
|
||||||
|
case MX:
|
||||||
|
parts := strings.SplitN(c, " ", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return parts[0] + " " + normalizeName(parts[1])
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
case SRV:
|
||||||
|
f := strings.Fields(c)
|
||||||
|
if len(f) == 4 {
|
||||||
|
return f[0] + " " + f[1] + " " + f[2] + " " + normalizeName(f[3])
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
case CNAME, NS:
|
||||||
|
return normalizeName(c)
|
||||||
|
default: // A, AAAA, SOA
|
||||||
|
return strings.ToLower(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizedValues returns sorted, normalized values.
|
||||||
|
func (r Record) NormalizedValues() []string {
|
||||||
|
out := make([]string, len(r.Values))
|
||||||
|
for i, v := range r.Values {
|
||||||
|
out[i] = normalizeValue(r.Type, v)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal reports whether two records have the same TTL and value set.
|
||||||
|
func (r Record) Equal(o Record) bool {
|
||||||
|
if r.TTL != o.TTL {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
a, b := r.NormalizedValues(), o.NormalizedValues()
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestManaged(t *testing.T) {
|
||||||
|
managed := []RecordType{A, AAAA, CNAME, MX, TXT, SRV}
|
||||||
|
for _, rt := range managed {
|
||||||
|
if !rt.Managed() {
|
||||||
|
t.Errorf("%s should be managed", rt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, rt := range []RecordType{NS, SOA} {
|
||||||
|
if rt.Managed() {
|
||||||
|
t.Errorf("%s should be read-only", rt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyNormalizesName(t *testing.T) {
|
||||||
|
r1 := Record{Type: A, Name: "www.Example.com"}
|
||||||
|
r2 := Record{Type: A, Name: "www.example.com."}
|
||||||
|
if r1.Key() != r2.Key() {
|
||||||
|
t.Fatalf("keys differ: %q vs %q", r1.Key(), r2.Key())
|
||||||
|
}
|
||||||
|
if r1.Key() != "A www.example.com." {
|
||||||
|
t.Fatalf("unexpected key %q", r1.Key())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEqualMXPriorityAndOrder(t *testing.T) {
|
||||||
|
a := Record{Type: MX, Name: "example.com.", TTL: 3600, Values: []string{"10 mx1.example.com.", "20 mx2.Example.com."}}
|
||||||
|
b := Record{Type: MX, Name: "example.com.", TTL: 3600, Values: []string{"20 mx2.example.com.", "10 mx1.example.com."}}
|
||||||
|
if !a.Equal(b) {
|
||||||
|
t.Fatal("MX records equal regardless of order and target case")
|
||||||
|
}
|
||||||
|
c := Record{Type: MX, Name: "example.com.", TTL: 3600, Values: []string{"30 mx1.example.com."}}
|
||||||
|
if a.Equal(c) {
|
||||||
|
t.Fatal("different priority must not be equal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEqualTXTCaseSensitive(t *testing.T) {
|
||||||
|
a := Record{Type: TXT, Name: "example.com.", TTL: 60, Values: []string{"v=DKIM1; p=AbC"}}
|
||||||
|
b := Record{Type: TXT, Name: "example.com.", TTL: 60, Values: []string{"v=DKIM1; p=abc"}}
|
||||||
|
if a.Equal(b) {
|
||||||
|
t.Fatal("TXT is case-sensitive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEqualTTLMatters(t *testing.T) {
|
||||||
|
a := Record{Type: A, Name: "example.com.", TTL: 300, Values: []string{"1.2.3.4"}}
|
||||||
|
b := Record{Type: A, Name: "example.com.", TTL: 600, Values: []string{"1.2.3.4"}}
|
||||||
|
if a.Equal(b) {
|
||||||
|
t.Fatal("different TTL must not be equal")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user