package diff2 import ( "fmt" "strings" "testing" "github.com/StackExchange/dnscontrol/v4/models" "github.com/StackExchange/dnscontrol/v4/pkg/js" "github.com/miekg/dns" testifyrequire "github.com/stretchr/testify/require" ) // parseZoneContents is copied verbatim from providers/bind/bindProvider.go // because import cycles and... tests shouldn't depend on huge modules. func parseZoneContents(content string, zoneName string, zonefileName string) (models.Records, error) { zp := dns.NewZoneParser(strings.NewReader(content), zoneName, zonefileName) foundRecords := models.Records{} for rr, ok := zp.Next(); ok; rr, ok = zp.Next() { rec, err := models.RRtoRCTxtBug(rr, zoneName) if err != nil { return nil, err } foundRecords = append(foundRecords, &rec) } if err := zp.Err(); err != nil { return nil, fmt.Errorf("error while parsing '%v': %w", zonefileName, err) } return foundRecords, nil } func showRecs(recs models.Records) string { result := "" for _, rec := range recs { result += (rec.GetLabel() + " " + rec.Type + " " + rec.GetTargetCombined() + "\n") } return result } func handsoffHelper(t *testing.T, existingZone, desiredJs string, noPurge bool, resultWanted string) { t.Helper() existing, err := parseZoneContents(existingZone, "f.com", "no_file_name") if err != nil { t.Fatal(err) } dnsconfig, err := js.ExecuteJavascriptString([]byte(desiredJs), false, nil) if err != nil { panic(err) } dc := dnsconfig.FindDomain("f.com") desired := dc.Records absences := dc.EnsureAbsent unmanagedConfigs := dc.Unmanaged // BUG(tlim): For some reason ExecuteJavascriptString() isn't setting the NameFQDN on records. // This fixes up the records. It is a crass workaround. We should find the real // cause and fix it. for i, j := range desired { desired[i].SetLabel(j.GetLabel(), "f.com") } for i, j := range absences { absences[i].SetLabel(j.GetLabel(), "f.com") } ignored, purged, err := processIgnoreAndNoPurge( "f.com", existing, desired, absences, unmanagedConfigs, noPurge, ) if err != nil { t.Fatal(err) } ignoredRecs := showRecs(ignored) purgedRecs := showRecs(purged) resultActual := "IGNORED:\n" + ignoredRecs + "FOREIGN:\n" + purgedRecs resultWanted = strings.TrimSpace(resultWanted) + "\n" resultActual = strings.TrimSpace(resultActual) + "\n" existingTxt := showRecs(existing) desiredTxt := showRecs(desired) debugTxt := "EXISTING:\n" + existingTxt + "DESIRED:\n" + desiredTxt if resultWanted != resultActual { testifyrequire.Equal(t, resultWanted, resultActual, "GOT =\n```\n%s```\nWANT=\n```%s```\nINPUTS=\n```\n%s\n```\n", resultActual, resultWanted, debugTxt) } } func Test_purge_empty(t *testing.T) { existingZone := ` foo1 IN A 1.1.1.1 foo2 IN A 2.2.2.2 ` desiredJs := ` D("f.com", "none", A("foo1", "1.1.1.1"), A("foo2", "2.2.2.2"), {}) ` handsoffHelper(t, existingZone, desiredJs, false, ` IGNORED: FOREIGN: `) } func Test_purge_1(t *testing.T) { existingZone := ` foo1 IN A 1.1.1.1 foo2 IN A 2.2.2.2 foo3 IN A 2.2.2.2 ` desiredJs := ` D("f.com", "none", A("foo1", "1.1.1.1"), A("foo2", "2.2.2.2"), {}) ` handsoffHelper(t, existingZone, desiredJs, false, ` IGNORED: FOREIGN: `) } func Test_nopurge_1(t *testing.T) { existingZone := ` foo1 IN A 1.1.1.1 foo2 IN A 2.2.2.2 foo3 IN A 3.3.3.3 ` desiredJs := ` D("f.com", "none", A("foo1", "1.1.1.1"), A("foo2", "2.2.2.2"), {}) ` handsoffHelper(t, existingZone, desiredJs, true, ` IGNORED: FOREIGN: foo3 A 3.3.3.3 `) } func Test_absent_1(t *testing.T) { existingZone := ` foo1 IN A 1.1.1.1 foo2 IN A 2.2.2.2 foo3 IN A 3.3.3.3 ` desiredJs := ` D("f.com", "none", A("foo1", "1.1.1.1"), A("foo2", "2.2.2.2"), A("foo3", "3.3.3.3", ENSURE_ABSENT_REC()), {}) ` handsoffHelper(t, existingZone, desiredJs, false, ` IGNORED: FOREIGN: `) } func Test_ignore_lab(t *testing.T) { existingZone := ` foo1 IN A 1.1.1.1 foo2 IN A 2.2.2.2 foo3 IN A 3.3.3.3 foo3 IN MX 10 mymx.example.com. ` desiredJs := ` D("f.com", "none", A("foo1", "1.1.1.1"), A("foo2", "2.2.2.2"), IGNORE_NAME("foo3"), {}) ` handsoffHelper(t, existingZone, desiredJs, false, ` IGNORED: foo3 A 3.3.3.3 foo3 MX 10 mymx.example.com. FOREIGN: `) } func Test_ignore_labAndType(t *testing.T) { existingZone := ` foo1 IN A 1.1.1.1 foo2 IN A 2.2.2.2 foo3 IN A 3.3.3.3 foo3 IN MX 10 mymx.example.com. ` desiredJs := ` D("f.com", "none", A("foo1", "1.1.1.1"), A("foo2", "2.2.2.2"), A("foo3", "3.3.3.3"), IGNORE_NAME("foo3", "MX"), {}) ` handsoffHelper(t, existingZone, desiredJs, false, ` IGNORED: foo3 MX 10 mymx.example.com. FOREIGN: `) } func Test_ignore_target(t *testing.T) { existingZone := ` foo1 IN A 1.1.1.1 foo2 IN A 2.2.2.2 _2222222222222222.cr IN CNAME _333333.nnn.acm-validations.aws. ` desiredJs := ` D("f.com", "none", A("foo1", "1.1.1.1"), A("foo2", "2.2.2.2"), MX("foo3", 10, "mymx.example.com."), IGNORE_TARGET('**.acm-validations.aws.', 'CNAME'), {}) ` handsoffHelper(t, existingZone, desiredJs, false, ` IGNORED: _2222222222222222.cr CNAME _333333.nnn.acm-validations.aws. FOREIGN: `) } // Test_ignore_external_dns tests the IGNORE_EXTERNAL_DNS feature // using the full handsoff() function. func Test_ignore_external_dns(t *testing.T) { domain := "f.com" // Existing zone has external-dns managed records existing := models.Records{ // External-dns TXT ownership record makeTestRecord("a-myapp", "TXT", "heritage=external-dns,external-dns/owner=k8s-cluster", domain), // The A record managed by external-dns makeTestRecord("myapp", "A", "10.0.0.1", domain), // Static record not managed by external-dns makeTestRecord("static", "A", "1.2.3.4", domain), // Another external-dns managed record makeTestRecord("cname-api", "TXT", "heritage=external-dns,external-dns/owner=k8s-cluster", domain), makeTestRecord("api", "CNAME", "myapp.f.com.", domain), } // Desired only has the static record desired := models.Records{ makeTestRecord("static", "A", "1.2.3.4", domain), } // Call handsoff with IGNORE_EXTERNAL_DNS enabled result, msgs, err := handsoff( domain, existing, desired, nil, // absences nil, // unmanagedConfigs false, // unmanagedSafely false, // noPurge true, // ignoreExternalDNS "", // externalDNSPrefix (empty = default) ) if err != nil { t.Fatal(err) } // Check that external-dns records are in the result (so they won't be deleted) foundMyappA := false foundMyappTXT := false foundApiCNAME := false foundApiTXT := false foundStatic := false for _, rec := range result { switch { case rec.GetLabel() == "myapp" && rec.Type == "A": foundMyappA = true case rec.GetLabel() == "a-myapp" && rec.Type == "TXT": foundMyappTXT = true case rec.GetLabel() == "api" && rec.Type == "CNAME": foundApiCNAME = true case rec.GetLabel() == "cname-api" && rec.Type == "TXT": foundApiTXT = true case rec.GetLabel() == "static" && rec.Type == "A": foundStatic = true } } if !foundMyappA { t.Error("Expected myapp A record to be preserved") } if !foundMyappTXT { t.Error("Expected a-myapp TXT record to be preserved") } if !foundApiCNAME { t.Error("Expected api CNAME record to be preserved") } if !foundApiTXT { t.Error("Expected cname-api TXT record to be preserved") } if !foundStatic { t.Error("Expected static A record to be preserved") } // Check that we got a message about external-dns records foundMsg := false for _, msg := range msgs { if strings.Contains(msg, "IGNORE_EXTERNAL_DNS") { foundMsg = true break } } if !foundMsg { t.Error("Expected message about IGNORE_EXTERNAL_DNS records") } } // Test_ignore_external_dns_custom_prefix tests IGNORE_EXTERNAL_DNS with custom prefix func Test_ignore_external_dns_custom_prefix(t *testing.T) { domain := "f.com" // Existing zone has external-dns managed records with custom prefix "extdns-" existing := models.Records{ // External-dns TXT ownership record with custom prefix makeTestRecord("extdns-www", "TXT", "heritage=external-dns,external-dns/owner=k3s-cluster", domain), // The A record managed by external-dns makeTestRecord("www", "A", "10.0.0.1", domain), // Static record makeTestRecord("static", "A", "1.2.3.4", domain), } // Desired only has the static record desired := models.Records{ makeTestRecord("static", "A", "1.2.3.4", domain), } // Call handsoff with custom prefix result, _, err := handsoff( domain, existing, desired, nil, // absences nil, // unmanagedConfigs false, // unmanagedSafely false, // noPurge true, // ignoreExternalDNS "extdns-", // externalDNSPrefix ) if err != nil { t.Fatal(err) } // Check that external-dns records with custom prefix are preserved foundWwwA := false foundWwwTXT := false for _, rec := range result { switch { case rec.GetLabel() == "www" && rec.Type == "A": foundWwwA = true case rec.GetLabel() == "extdns-www" && rec.Type == "TXT": foundWwwTXT = true } } if !foundWwwA { t.Error("Expected www A record to be preserved with custom prefix") } if !foundWwwTXT { t.Error("Expected extdns-www TXT record to be preserved with custom prefix") } } // Test_ignore_external_dns_conflict tests conflict detection func Test_ignore_external_dns_conflict(t *testing.T) { domain := "f.com" // Existing zone has external-dns managed record existing := models.Records{ makeTestRecord("a-myapp", "TXT", "heritage=external-dns,external-dns/owner=k8s-cluster", domain), makeTestRecord("myapp", "A", "10.0.0.1", domain), } // Desired ALSO has myapp - this is a conflict! desired := models.Records{ makeTestRecord("myapp", "A", "192.168.1.1", domain), // Different IP } // Call handsoff with IGNORE_EXTERNAL_DNS enabled result, msgs, err := handsoff( domain, existing, desired, nil, // absences nil, // unmanagedConfigs false, // unmanagedSafely false, // noPurge true, // ignoreExternalDNS "", // externalDNSPrefix ) if err != nil { t.Fatal(err) } // Should get a warning about the conflict foundConflictWarning := false for _, msg := range msgs { if strings.Contains(msg, "WARNING") && strings.Contains(msg, "external-dns") { foundConflictWarning = true break } } if !foundConflictWarning { t.Error("Expected warning about conflict between desired and external-dns records") } // The desired record should be in result (not duplicated) myappCount := 0 for _, rec := range result { if rec.GetLabel() == "myapp" && rec.Type == "A" { myappCount++ } } if myappCount != 1 { t.Errorf("Expected exactly 1 myapp A record in result, got %d", myappCount) } }