From cbffda63f56f452161275dd48108f8d45d383eba Mon Sep 17 00:00:00 2001 From: Tomas Boros Date: Wed, 26 Aug 2026 16:09:58 +0200 Subject: [PATCH 1/2] fix(controller): implement SOA serial increment logic in ReplaceRRSet and add corresponding tests for PowerDNS --- internal/dns/pdns/client.go | 65 +++++++++++++++++++ internal/dns/pdns/pdns_test.go | 114 +++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) diff --git a/internal/dns/pdns/client.go b/internal/dns/pdns/client.go index 65c5711..e1cbdd3 100644 --- a/internal/dns/pdns/client.go +++ b/internal/dns/pdns/client.go @@ -683,6 +683,24 @@ func (c *Client) ReplaceRRSet( observedGeneration int64, objectUID string, ) error { + if recordType == string(dnsv1alpha1.RRTypeSOA) { + c.logger.Info("Updating SOA record: fetching current serial", "zone", zone, "owner", ownerName) + nextSerial, hasExistingSOA, err := c.nextSOASerial(ctx, zone, ownerName) + if err != nil { + return err + } + if hasExistingSOA { + c.logger.Info("Updating SOA record: incremented serial", "zone", zone, "owner", ownerName, "nextSerial", nextSerial) + values, err = applySOASerial(values, nextSerial) + if err != nil { + return err + } + c.logger.Info("Updating SOA record: applied incremented serial to desired value", "zone", zone, "owner", ownerName) + } else { + c.logger.Info("Updating SOA record: no existing SOA in PDNS, keeping desired serial", "zone", zone, "owner", ownerName) + } + } + records := make([]rrsetRecord, 0, len(values)) for _, v := range values { if v == "" { @@ -717,6 +735,53 @@ func (c *Client) ReplaceRRSet( return c.applyRRSetPatch(ctx, zone, patch) } +func (c *Client) nextSOASerial(ctx context.Context, zone, ownerName string) (string, bool, error) { + rrsets, err := c.getPDNSRRSet(ctx, zone, QualifyOwner(ownerName, zone), dnsv1alpha1.RRTypeSOA) + if err != nil { + return "", false, err + } + if len(rrsets) == 0 { + return "", false, nil + } + if len(rrsets) > 1 { + return "", true, fmt.Errorf("multiple SOA rrsets returned for owner %s", ownerName) + } + + for _, rec := range rrsets[0].Records { + fields := strings.Fields(rec.Content) + if len(fields) < 3 { + continue + } + current, parseErr := strconv.ParseUint(fields[2], 10, 64) + if parseErr != nil { + return "", true, fmt.Errorf("parse current SOA serial %q: %w", fields[2], parseErr) + } + c.logger.Info("Fetched existing SOA serial", "zone", zone, "owner", ownerName, "currentSerial", fields[2]) + return strconv.FormatUint(current+1, 10), true, nil + } + + return "", true, fmt.Errorf("SOA rrset for owner %s has no parseable records", ownerName) +} + +func applySOASerial(values []string, serial string) ([]string, error) { + out := make([]string, 0, len(values)) + updated := false + for _, value := range values { + fields := strings.Fields(value) + if len(fields) < 3 { + out = append(out, value) + continue + } + fields[2] = serial + out = append(out, strings.Join(fields, " ")) + updated = true + } + if !updated { + return nil, fmt.Errorf("failed to apply SOA serial %s to desired values", serial) + } + return out, nil +} + // DeleteRRSet removes the referenced (type, owner) RRset from PDNS. func (c *Client) DeleteRRSet(ctx context.Context, zone, recordType, ownerName string) error { patch := []rrset{{ diff --git a/internal/dns/pdns/pdns_test.go b/internal/dns/pdns/pdns_test.go index be8147b..c73144e 100644 --- a/internal/dns/pdns/pdns_test.go +++ b/internal/dns/pdns/pdns_test.go @@ -126,6 +126,120 @@ func TestCreateGetDeleteZoneAndRRSets(t *testing.T) { } } +func TestReplaceRRSet_SOAIncrementsCurrentSerial(t *testing.T) { + t.Parallel() + + var getCalls int + var patchCalls int + var captured patchZoneRequest + + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/servers/localhost/zones/example.com.", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + getCalls++ + _ = json.NewEncoder(w).Encode(zoneResponse{ + Name: exampleCom, + RRSets: []zoneRRset{{ + Name: exampleCom, + Type: "SOA", + TTL: 300, + Records: []zoneRRsetRecord{{ + Content: "ns1.example.net. hostmaster.example.net. 2026082607 10800 3600 604800 3600", + }}, + }}, + }) + case http.MethodPatch: + patchCalls++ + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &captured) + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + }) + + ts := httptest.NewServer(mux) + defer ts.Close() + + c := NewClient(ts.URL, "key") + err := c.ReplaceRRSet( + context.Background(), + "example.com", + "SOA", + "@", + 300, + []string{"ns1.example.net. hostmaster.example.net. 2026082601 10800 3600 604800 3600"}, + "default:rs", + 42, + "uid-1", + ) + if err != nil { + t.Fatalf("ReplaceRRSet error: %v", err) + } + + if getCalls != 1 { + t.Fatalf("expected 1 GET call to fetch SOA serial, got %d", getCalls) + } + if patchCalls != 1 { + t.Fatalf("expected 1 PATCH call, got %d", patchCalls) + } + if len(captured.RRSets) != 1 || len(captured.RRSets[0].Records) != 1 { + t.Fatalf("unexpected patch payload: %#v", captured) + } + serial := strings.Fields(captured.RRSets[0].Records[0].Content)[2] + if serial != "2026082608" { + t.Fatalf("expected incremented SOA serial 2026082608, got %s", serial) + } +} + +func TestReplaceRRSet_NonSOADoesNotFetchSerial(t *testing.T) { + t.Parallel() + + var getCalls int + var patchCalls int + + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/servers/localhost/zones/example.com.", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + getCalls++ + _ = json.NewEncoder(w).Encode(zoneResponse{Name: exampleCom, RRSets: nil}) + case http.MethodPatch: + patchCalls++ + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + }) + + ts := httptest.NewServer(mux) + defer ts.Close() + + c := NewClient(ts.URL, "key") + err := c.ReplaceRRSet( + context.Background(), + "example.com", + "A", + "www", + 300, + []string{"1.2.3.4"}, + "default:rs", + 42, + "uid-1", + ) + if err != nil { + t.Fatalf("ReplaceRRSet error: %v", err) + } + + if getCalls != 0 { + t.Fatalf("expected no GET calls for non-SOA updates, got %d", getCalls) + } + if patchCalls != 1 { + t.Fatalf("expected 1 PATCH call, got %d", patchCalls) + } +} + func TestBuildRRSets_NormalizationAndFormats(t *testing.T) { t.Parallel() From 9804e6d57d7a42d2eeafbfd5a7e01d1080765a70 Mon Sep 17 00:00:00 2001 From: Tomas Boros Date: Thu, 27 Aug 2026 09:45:50 +0200 Subject: [PATCH 2/2] fix(controller): update SOA serial handling to reuse current serial instead of incrementing --- internal/dns/pdns/client.go | 12 ++++++------ internal/dns/pdns/pdns_test.go | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/dns/pdns/client.go b/internal/dns/pdns/client.go index e1cbdd3..ababf48 100644 --- a/internal/dns/pdns/client.go +++ b/internal/dns/pdns/client.go @@ -685,17 +685,17 @@ func (c *Client) ReplaceRRSet( ) error { if recordType == string(dnsv1alpha1.RRTypeSOA) { c.logger.Info("Updating SOA record: fetching current serial", "zone", zone, "owner", ownerName) - nextSerial, hasExistingSOA, err := c.nextSOASerial(ctx, zone, ownerName) + curSerial, hasExistingSOA, err := c.getSOASerial(ctx, zone, ownerName) if err != nil { return err } if hasExistingSOA { - c.logger.Info("Updating SOA record: incremented serial", "zone", zone, "owner", ownerName, "nextSerial", nextSerial) - values, err = applySOASerial(values, nextSerial) + c.logger.Info("Updating SOA record: reusing current serial, PDNS will increment on write", "zone", zone, "owner", ownerName, "curSerial", curSerial) + values, err = applySOASerial(values, curSerial) if err != nil { return err } - c.logger.Info("Updating SOA record: applied incremented serial to desired value", "zone", zone, "owner", ownerName) + c.logger.Info("Updating SOA record: applied current serial to desired value", "zone", zone, "owner", ownerName) } else { c.logger.Info("Updating SOA record: no existing SOA in PDNS, keeping desired serial", "zone", zone, "owner", ownerName) } @@ -735,7 +735,7 @@ func (c *Client) ReplaceRRSet( return c.applyRRSetPatch(ctx, zone, patch) } -func (c *Client) nextSOASerial(ctx context.Context, zone, ownerName string) (string, bool, error) { +func (c *Client) getSOASerial(ctx context.Context, zone, ownerName string) (string, bool, error) { rrsets, err := c.getPDNSRRSet(ctx, zone, QualifyOwner(ownerName, zone), dnsv1alpha1.RRTypeSOA) if err != nil { return "", false, err @@ -757,7 +757,7 @@ func (c *Client) nextSOASerial(ctx context.Context, zone, ownerName string) (str return "", true, fmt.Errorf("parse current SOA serial %q: %w", fields[2], parseErr) } c.logger.Info("Fetched existing SOA serial", "zone", zone, "owner", ownerName, "currentSerial", fields[2]) - return strconv.FormatUint(current+1, 10), true, nil + return strconv.FormatUint(current, 10), true, nil } return "", true, fmt.Errorf("SOA rrset for owner %s has no parseable records", ownerName) diff --git a/internal/dns/pdns/pdns_test.go b/internal/dns/pdns/pdns_test.go index c73144e..322485e 100644 --- a/internal/dns/pdns/pdns_test.go +++ b/internal/dns/pdns/pdns_test.go @@ -126,7 +126,7 @@ func TestCreateGetDeleteZoneAndRRSets(t *testing.T) { } } -func TestReplaceRRSet_SOAIncrementsCurrentSerial(t *testing.T) { +func TestReplaceRRSet_SOAReusesCurrentSerial(t *testing.T) { t.Parallel() var getCalls int @@ -188,8 +188,8 @@ func TestReplaceRRSet_SOAIncrementsCurrentSerial(t *testing.T) { t.Fatalf("unexpected patch payload: %#v", captured) } serial := strings.Fields(captured.RRSets[0].Records[0].Content)[2] - if serial != "2026082608" { - t.Fatalf("expected incremented SOA serial 2026082608, got %s", serial) + if serial != "2026082607" { + t.Fatalf("expected current SOA serial 2026082607 to be reused, got %s", serial) } }