Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/controller/conditions.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ const (
ReasonDNSZoneInUse = "DNSZoneInUse"
ReasonNotOwner = "NotOwner"
ReasonPDNSError = "PDNSError"
ReasonConflict = "Conflict"
)
9 changes: 8 additions & 1 deletion internal/controller/dnsrecordset_powerdns_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,14 @@ func (r *DNSRecordSetPowerDNSReconciler) setRecordProgrammedCondition(
newCond.Message = "Another DNSRecordSet owns this record"
case pdnsErr != nil:
newCond.Status = metav1.ConditionFalse
newCond.Reason = ReasonPDNSError
if pdnsclient.IsConflict(pdnsErr) {
// CNAME/ALIAS coexistence conflict: well-formed record, but it
// cannot share this name with existing data. Distinct reason so it
// is greppable and not mistaken for a transient provider error.
newCond.Reason = ReasonConflict
} else {
newCond.Reason = ReasonPDNSError
}
newCond.Message = pdnsclient.FriendlyMessage(pdnsErr)
default:
newCond.Status = metav1.ConditionTrue
Expand Down
10 changes: 7 additions & 3 deletions internal/pdns/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -456,8 +456,10 @@ func buildRRSets(zone string, rs dnsv1alpha1.DNSRecordSet) []rrset {
}
target := strings.TrimSpace(rec.CNAME.Content)
target = qualifyIfNeeded(target)
if target != "" {
// TODO: Technically this is a violation of the RFC, but we'll allow it for now.
if target != "" && len(r.Records) == 0 {
// CNAME is single-valued (RFC 1034): keep exactly one record.
// First non-empty entry wins; extras/duplicates are dropped, as
// PowerDNS rejects a multi-valued or duplicate CNAME RRset (422).
r.Records = append(r.Records, rrsetRecord{Content: target, Disabled: false})
}

Expand Down Expand Up @@ -604,7 +606,9 @@ func buildRRSets(zone string, rs dnsv1alpha1.DNSRecordSet) []rrset {
}
target := strings.TrimSpace(rec.ALIAS.Content)
target = qualifyIfNeeded(target)
if target != "" {
if target != "" && len(r.Records) == 0 {
// ALIAS is single-valued at an owner name: keep one record.
// First non-empty entry wins; extras/duplicates are dropped.
r.Records = append(r.Records, rrsetRecord{Content: target, Disabled: false})
}

Expand Down
19 changes: 19 additions & 0 deletions internal/pdns/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,22 @@ func FriendlyMessage(err error) string {
return "Failed to apply DNS record. It will be retried automatically."
}
}

// IsConflict reports whether err is a PowerDNS rejection caused by a record
// coexistence conflict — a CNAME or ALIAS RRset that cannot share an owner name
// with a pre-existing RRset. This is distinct from a malformed payload: the
// record is well-formed but conflicts with existing data at the same name.
func IsConflict(err error) bool {
if err == nil {
return false
}
var apiErr *pdnsAPIError
if !errors.As(err, &apiErr) || apiErr.Status != 422 {
return false
}
var body pdnsErrorBody
if apiErr.Body != "" {
_ = json.Unmarshal([]byte(apiErr.Body), &body)
}
return strings.Contains(body.Error, "Conflicts with pre-existing RRset")
}
37 changes: 37 additions & 0 deletions internal/pdns/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,40 @@ func TestFriendlyMessage(t *testing.T) {
})
}
}

func TestIsConflict(t *testing.T) {
t.Parallel()

tests := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"non-pdns error", errors.New("boom"), false},
{
"ALIAS coexistence conflict",
&pdnsAPIError{Status: 422, Body: `{"error": "RRset www.ab.dk. IN ALIAS: Conflicts with pre-existing RRset"}`},
true,
},
{
"non-conflict 422 (duplicate record)",
&pdnsAPIError{Status: 422, Body: `{"error": "RRset x. IN NS: duplicate record with content \"ns1.\""}`},
false,
},
{
"non-422 status",
&pdnsAPIError{Status: 500, Body: `{"error": "Conflicts with pre-existing RRset"}`},
false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := IsConflict(tt.err); got != tt.want {
t.Errorf("IsConflict() = %v, want %v", got, tt.want)
}
})
}
}
38 changes: 38 additions & 0 deletions internal/pdns/pdns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,44 @@ func TestApplyRecordSetAuthoritative_PathAndHeaders(t *testing.T) {
}
}

// CNAME and ALIAS are single-valued at an owner name: multiple/duplicate
// entries must collapse to exactly one record (PowerDNS rejects otherwise, 422).
func TestBuildRRSets_CNAMEAliasSingleValue(t *testing.T) {
t.Parallel()

rsCNAME := dnsv1alpha1.DNSRecordSet{
Spec: dnsv1alpha1.DNSRecordSetSpec{
RecordType: dnsv1alpha1.RRTypeCNAME,
Records: []dnsv1alpha1.RecordEntry{
{Name: "www", CNAME: &dnsv1alpha1.CNAMERecordSpec{Content: "target.example.net."}},
{Name: "www", CNAME: &dnsv1alpha1.CNAMERecordSpec{Content: "target.example.net."}},
{Name: "www", CNAME: &dnsv1alpha1.CNAMERecordSpec{Content: "other.example.net."}},
},
},
}
rr := buildRRSets("example.com", rsCNAME)
if len(rr) != 1 || len(rr[0].Records) != 1 {
t.Fatalf("CNAME single-value: expected 1 rrset/1 record, got %#v", rr)
}
if rr[0].Records[0].Content != "target.example.net." {
t.Fatalf("CNAME single-value: first-wins expected target.example.net., got %q", rr[0].Records[0].Content)
}

rsALIAS := dnsv1alpha1.DNSRecordSet{
Spec: dnsv1alpha1.DNSRecordSetSpec{
RecordType: dnsv1alpha1.RRTypeALIAS,
Records: []dnsv1alpha1.RecordEntry{
{Name: "@", ALIAS: &dnsv1alpha1.ALIASRecordSpec{Content: "lb.example.net."}},
{Name: "@", ALIAS: &dnsv1alpha1.ALIASRecordSpec{Content: "lb.example.net."}},
},
},
}
rr = buildRRSets("example.com", rsALIAS)
if len(rr) != 1 || len(rr[0].Records) != 1 {
t.Fatalf("ALIAS single-value: expected 1 rrset/1 record, got %#v", rr)
}
}

// sanity: makeSimpleRRSet keeps values verbatim (used after we normalize)
func TestMakeSimpleRRSet(t *testing.T) {
t.Parallel()
Expand Down
Loading