Skip to content

TEST: Add golden-file tests for provider record conversion - #4653

Open
shuvamk wants to merge 15 commits into
DNSControl:release_candidate_v5from
shuvamk:test/provider-conversion-goldens
Open

TEST: Add golden-file tests for provider record conversion#4653
shuvamk wants to merge 15 commits into
DNSControl:release_candidate_v5from
shuvamk:test/provider-conversion-goldens

Conversation

@shuvamk

@shuvamk shuvamk commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

This is the harness from #4622, built to the decisions in #4622 (comment).

It is the harness plus three providers. Enrolling the rest is mechanical and I am happy to do it in follow-up PRs — see the last section.

What it does

Record a provider's native API data once. go test replays it through that provider's conversion functions and compares the result with a checked-in golden file. No credentials, no network.

providers/websupport/testdata/websupport_torecordconfig.json      recorded input
providers/websupport/testdata/websupport_torecordconfig.golden    expected output

The whole of what a provider author writes is an adapter that maps their local signature onto a uniform one:

func TestToRecordConfigGolden(t *testing.T) {
	providergolden.CheckToRC(t, "websupport_torecordconfig", testDomain,
		func(dc *models.DomainConfig, native nativeRecord) ([]*models.RecordConfig, error) {
			rc, err := toRecordConfig(dc, native)
			return []*models.RecordConfig{rc}, err
		})
}

That closure is what absorbs the differing function names, argument orders, and one-vs-many returns. websupport.toNative and porkbun.toReq already match the target signature, so their tests are one line.

The decisions, and where each one landed

# Decision Where
1 Base release_candidate_v5 this PR's base
2 LineString() + metadata, no TTL and no space after it when the TTL is 0 formatRecord, pkg/providergolden/providergolden.go — see the note below
3 -update is (a) only, (b) is separate -update rewrites .golden only; it never rewrites a recorded input and never opens a socket
4 autogold: convert pkg/js/parse_tests separately read as "that conversation is about parse_tests"; -update is implemented here directly, no new dependency. If you would rather this used autogold, say so and I will switch it
5 Runs in go test yes, go test ./...
6 One file per provider per function, provider name in lower case websupport_torecordconfig, porkbun_toreq, …

On decision 2, one choice you should overrule if you disagree. formatRecord reimplements the line rather than calling models.LineString(), because the requested format differs from what LineString() returns today (no TTL when zero, plus metadata) and changing LineString() would change behaviour outside the tests — providers/tencentdns/convert_test.go asserts against it. The cost is that there are now two definitions of the line format that can drift. LineString()'s own doc comment says "This may change some day to include metadata and other fields, skip zero TTLs, and more", so if you meant for LineString() itself to move, say so: the harness then becomes a one-line call and the drift risk goes away. I did not want to make that call for you inside a test PR.

And:

  • toRC() and equivalentsCheckToRC. toNative/createRequest equivalents where it is easyCheckToNative, wired up for all three providers.
  • A provider with no recorded data must still pass. It skips. Verified below, both for "no data at all" and for "input recorded but golden not generated yet".

Metadata is appended after a ;, sorted, values quoted, so a line stays greppable and a diff stays one line per record. porkbun's URL forwarding records also carry no TTL, so they show both rules at once:

fwd IN URL https://example.net/landing ; includePath="no" type="temporary" wildcard="no"

The zero-TTL rule is pinned directly by TestFormatRecord/zero_TTL_is_omitted in the harness's own tests.

The .golden a CheckToRC test produces is a valid input file for a CheckToNative test, which is how the toNative side gets its input without a second recording format.

Providers enrolled

websupport — chosen because it already has a hand-written convert_test.go (yours after #4584, the author's before that), so its goldens can be checked against expectations somebody wrote independently of this harness. All six of its cases agree exactly:

convert_test.go expects websupport_tonative.golden
A @1.2.3.4 {"type":"A","name":"@","content":"1.2.3.4","ttl":3600}
CNAME wwwghs.example.net (dot stripped) {"type":"CNAME","name":"www","content":"ghs.example.net",…}
MX @mail.example.com {"type":"MX","name":"@","content":"mail.example.com","priority":10,…}
SRV _sip._tcpsip.example.com {"type":"SRV","name":"_sip._tcp","content":"sip.example.com","priority":10,"port":5060,"weight":20,…}
AAAA ipv62a00:4b40:aaaa:2001::6 {"type":"AAAA","name":"ipv6","content":"2a00:4b40:aaaa:2001::6",…}
TXT @hello world {"type":"TXT","name":"@","content":"hello world",…}

and websupport_torecordconfig.golden reproduces the RDATA that test's mkRC() builds, record for record. TestRoundTrip still passes unchanged alongside the new tests.

packetframe and porkbun — neither had a test file of any kind, and neither is in the integration matrix. Between the three there are both directions, three naming conventions, an extra parameter (packetframe.toReq(zoneID, rc)), metadata (porkbun's URL forwarding), and SVCB/HTTPS, which b3d616fe (PORKBUN: Modernize field access for SVCB records, #4649) rewrote two commits before the rc3 tag.

Where the recorded data came from, stated plainly: I do not have accounts with these providers. The inputs are written from each provider's own native struct and the content shapes its converter parses, cross-checked against websupport's existing test where one exists. They are realistic, not authentic. A provider author with credentials should replace the .json and .records files with a real recording — that is a manual file swap, after which -update regenerates the goldens from the new inputs.

Verification

Everything below was executed on 779898cc. Test output is verbatim; the only edit is that github.com/DNSControl/dnscontrol/v5/providers/... is shortened to .../providers/... in the SKIP and -update transcripts.

A wrong value in a golden fails. Three independent corruptions, each reverted afterwards:

websupport toRC golden, one label character: www -> wwww

--- FAIL: TestToRecordConfigGolden (0.00s)
    convert_golden_test.go:11: websupport_torecordconfig.golden does not match the conversion (-want +got):
          []string{
          	"@ 3600 IN A 1.2.3.4",
          	"ipv6 3600 IN AAAA 2a00:4b40:aaaa:2001::6",
          	strings.Join({
        - 		"w",
          		"www 3600 IN CNAME ghs.example.net.",
          	}, ""),
          	"@ 3600 IN MX 10 mail.example.com.",
          	"_sip._tcp 3600 IN SRV 10 20 5060 sip.example.com.",
          	... // 2 identical elements
          }
FAIL
FAIL	github.com/DNSControl/dnscontrol/v5/providers/websupport	0.565s

websupport toNative golden, a trailing dot the API must not be sent:

--- FAIL: TestToNativeGolden (0.00s)
    convert_golden_test.go:19: websupport_tonative.golden does not match the conversion (-want +got):
          []string{
          	... // 14 identical elements
          	`    "type": "CNAME",`,
          	`    "name": "www",`,
          	strings.Join({
          		`    "content": "ghs.example.net`,
        - 		".",
          		`",`,
          	}, ""),
          	`    "ttl": 3600`,
          	"  },",
          	... // 24 identical elements
          }
FAIL
FAIL	github.com/DNSControl/dnscontrol/v5/providers/websupport	0.230s

porkbun toRC golden, one SVCB parameter: port="8443" -> port="9443"

--- FAIL: TestToRcGolden (0.00s)
    convert_golden_test.go:11: porkbun_torc.golden does not match the conversion (-want +got):
          []string{
          	... // 10 identical elements
          	"@ 600 IN SSHFP 1 2 0123456789ABCDEF0123456789ABCDEF0123456789ABC"...,
          	`@ 600 IN HTTPS 1 . alpn="h3,h2" ipv4hint="192.0.2.1"`,
          	strings.Join({
          		`_8443._foo 600 IN SVCB 16 svc.example.net. port="`,
        - 		"9",
        + 		"8",
          		`443" alpn="h2"`,
          	}, ""),
          	"",
          }
FAIL
FAIL	github.com/DNSControl/dnscontrol/v5/providers/porkbun	0.449s

A provider with no recorded data passes. Both halves, plus a provider that was never enrolled:

$ mv providers/websupport/testdata /tmp/          # nothing recorded
    convert_golden_test.go:11: websupport_torecordconfig has no recorded data yet
--- SKIP: TestToRecordConfigGolden
    convert_golden_test.go:19: websupport_tonative has no recorded data yet
--- SKIP: TestToNativeGolden
ok  	.../providers/websupport	0.229s

$ mv providers/websupport/testdata/websupport_torecordconfig.golden /tmp/   # input recorded, golden not generated
    convert_golden_test.go:11: testdata/websupport_torecordconfig.golden does not exist: run "go test . -update" to record it
--- SKIP: TestToRecordConfigGolden
ok  	.../providers/websupport	0.232s

$ go test ./providers/netcup/
?   	.../providers/netcup	[no test files]

-update reproduces the committed goldens byte for byte:

$ go test -count=1 ./providers/websupport/ ./providers/packetframe/ ./providers/porkbun/ -update
ok  	.../providers/websupport	0.242s
ok  	.../providers/packetframe	0.406s
ok  	.../providers/porkbun	0.573s
$ git status --porcelain
$

Full local gate, base release_candidate_v5 @ 1c629cfc (v5.0.0-rc3):

command pristine base this branch
go test -count=1 ./... 70 ok / 39 no test files / 0 FAIL 73 ok / 37 no test files / 0 FAIL
golangci-lint run ./... 0 issues
staticcheck ./... clean
go vet ./... clean
go build + bin/fmtjson + go mod tidy + go generate ./... + go fmt ./... + go fix ./..., then git status 0-file diff (go.mod/go.sum unchanged, no new dependency)
BIND_DOMAIN=example.com go test ./integrationTest/ -args -provider BIND ok, 0.573s

The packages that change state are packetframe and porkbun (no test files → ok) and pkg/providergolden itself, which has its own tests for the line format, the parser, the skip-when-missing behaviour and the diff. websupport was already ok.

Deliberately not in this PR

  • Recording inputs from a live account — mode (b). Capturing the input to a converter means hooking each provider's API client, so it is its own change; nothing here blocks it, and the file layout already assumes it. Happy to take it next if you want it.
  • The other ~61 providers. One file of ~15 lines each plus a recording. I would rather send them in batches you can actually review than one 60-file PR, and the harness is designed so an unenrolled provider costs nothing.
  • The 16 providers whose conversion is inlined in GetZoneRecords (hetznerv2, vercel, …). Those need the function extracted first, which is a change to provider code and not a test change.
  • pkg/js/parse_tests / autogold, per decision 4.
  • netcup. I had it enrolled and took it back out: its current fromRecordConfig output would have codified a behaviour change, and a test-harness PR is the wrong place either to bless that or to fix it. Filed separately as an issue.

Implements the harness requested in DNSControl#4622: record a provider's native API
data once, replay it through the provider's conversion functions in
"go test", and compare the result with a checked-in golden file. No
credentials, no network.

Following the decisions in
DNSControl#4622 (comment):

1. Based on release_candidate_v5.
2. A golden line is LineString() plus the metadata, with the TTL and the
   space after it omitted when the TTL is zero. The format is built in
   the harness rather than by calling models.LineString(), so that
   LineString() keeps its current output.
3. -update rewrites only the golden files. The recorded inputs are never
   rewritten, so refreshing a golden never reaches for an API token.
   Gathering inputs from a live account stays a separate step.
5. Runs in "go test".
6. One data file per provider per function tested, named after the
   provider in lower case.

Providers are enrolled one at a time by adding a short adapter that maps
the provider's local signature onto a uniform one. A provider with no
recorded data is skipped rather than failed, so the providers that are
not enrolled yet stay green.

Enrolled here: websupport (toRecordConfig, toNative), packetframe (toRc,
toReq) and porkbun (toRc, toReq). websupport's goldens are checked
against the expectations in its existing hand-written convert_test.go;
packetframe and porkbun had no test file of any kind. The recorded
inputs are written from each provider's own native struct and the shapes
its converter parses, not captured from a live account, so a provider
author with credentials should expect to swap them out.

Point 4 (autogold) is read as applying to the pkg/js/parse_tests
conversion, which is a separate PR, so -update is implemented here
directly and adds no dependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TomOnTime

Copy link
Copy Markdown
Collaborator

@shuvamk

Changes to the requirements

  • formatRecord() should always include TTL even if it is zero. Update related functions. parseRecords() should be much more simple as a result of this change.
  • The data gathered should be collected from running the tests in integrationTest/. We currently run them with a shell command like go test -run TestDNSProviders -timeout 1h -failfast -v ./integrationTest -args -verbose -profile CLOUDFLAREAPI. You'll need to figure how how to do that without executing a shell command.

Responses to your earlier questions:

  • Recording inputs from a live account — mode (b). Do this next.
  • The other ~61 providers: These are the providers I have credentials for. Do them next: BIND CLOUDFLAREAPI GCLOUD ROUTE53 AXFRDDNS AZURE_DNS AZURE_PRIVATE_DNS CLOUDNS CNRDIGITALOCEAN GANDI_V5 HEDNS MYTHICBEASTS
    NAMEDOTCOM NETNOD NS1 POWERDNS TRANSIP VERCEL LUADNS NETLIFY
  • The 16 providers whose conversion is inlined in GetZoneRecords: Extract the function. Be careful to not have a function signature that is very large. For example, there is often a variable named "origin" or "domain" is the same as dc.Name. No need to pass origin/domain to the function.
  • Do the above in this PR.
  • Do pkg/js/parse_tests in another PR as already stated.
  • netcup: Skip it.

@TomOnTime

Copy link
Copy Markdown
Collaborator

@shuvamk Please create a PR for #4358

formatRecord() dropped the TTL and its trailing space when the TTL was
zero, so a zero-TTL record rendered as "fwd IN URL ..." instead of
"fwd 0 IN URL ...". parseRecords() then had to guess whether the second
field of a line was a TTL or the class in order to read that back.

The TTL is now written unconditionally. parseRecord() splits a line into
its five fixed fields in one step and checks the count once, instead of
cutting the line field by field and testing each cut. The conditional
that guessed whether the second field was a TTL is gone, and the whole
function goes from 40 lines to 32.

Two side effects of that rewrite, both in error reporting. A TTL that
does not parse is now named as such: "www abc IN A 192.0.2.1" reported
`expected class "IN"` and now reports `strconv.ParseUint: parsing "abc":
invalid syntax`. And parseRecord() no longer shadows its "line"
parameter with the metadata-stripped record, so its error messages quote
the whole line rather than truncating it at the semicolon.

All six .golden files were regenerated with "go test <pkg> -update" and
are byte-identical: none of the recorded records has a zero TTL. The
only recorded input that encoded the old format is porkbun's .records,
where the URL and URL301 records now carry an explicit 0.

Requested by @TomOnTime in
DNSControl#4653 (comment)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@shuvamk

shuvamk commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Ask 1 is done. Asks 2, 3, 4 and 5 are not in this push — detail at the bottom, but flagging it up front since you asked for all of them in this PR.

formatRecord() now always writes the TTL. A zero-TTL record renders fwd 0 IN URL https://example.net/landing ; … instead of fwd IN URL ….

parseRecords() did get simpler, as you predicted. It previously had to guess whether the second field of a line was a TTL or the class, cutting the line field by field and testing each cut. It now splits into five fixed fields in one step and checks the count once. parseRecord() goes from 40 lines to 32.

Two side effects of that rewrite, both in error reporting, both improvements but worth naming since they were not part of the ask:

  • A TTL that does not parse is now named as such. www abc IN A 192.0.2.1 used to report expected class "IN"; it now reports strconv.ParseUint: parsing "abc": invalid syntax.
  • parseRecord() no longer shadows its line parameter with the metadata-stripped record, so errors quote the whole line instead of truncating at the semicolon.

Goldens: I ran go test ./providers/{porkbun,packetframe,websupport}/ -update. All six .golden files were rewritten and all six are byte-identical — none of the recorded records has a zero TTL, so there was nowhere for a 0 to appear. The only file that encoded the old format is providers/porkbun/testdata/porkbun_toreq.records, where the URL and URL301 lines now carry an explicit 0. That does not move porkbun_toreq.golden, because porkbun's native forward objects have no ttl field at all.

Tests: TestFormatRecord/zero_TTL_is_omitted is inverted and renamed zero_TTL_is_included; the round-trip input gained the explicit 0; and TestParseRecordsRejectsMalformedInput gained no ttl, non-numeric ttl and wrong class rows, with no class renamed too few fields to match what it now exercises. Counting leaf tests: reverting only providergolden.go turns three red — zero_TTL_is_included, TestParseRecordsRoundTrip and no ttl — and leaves the other sixteen green; restoring it turns all nineteen green. non-numeric ttl and wrong class pass under both revisions; they are there to keep every error branch in parseRecord() covered, not to discriminate.

Local run on release_candidate_v5 @ 1c629cf: go build . ok; go test ./... 73 ok / 37 no-test / 0 fail; golangci-lint 0 issues; staticcheck and go vet clean; the six go-checks commands leave a 0-file diff; BIND_DOMAIN=example.com go test ./integrationTest/ -args -provider BIND passes.

On models.LineString(), which I asked about last time: after this change the two formats are identical for a record with no metadata. LineString() is fmt.Sprintf("%s %d IN %s %s", rc.Name, rc.TTL, rc.Type, rc.GetRDATA().String()), and that is now exactly what formatRecord() emits. What still separates them is the metadata tail — the golden format appends sorted key="value" pairs after a ;, and LineString() has nowhere to put them. So I have left them as two functions. If you would rather the harness call LineString() and handle metadata some other way, say so and I will do it in this PR.

On the rest of your list: asks 2, 3, 4 and 5 — recording from integrationTest/ without shelling out, live input capture, the 21-provider enrolment, and extracting the converters inlined in GetZoneRecords — are not in this push. I wanted the line format settled before recording anything, because every .records file we capture depends on it, and re-recording 21 providers against a format that then changes is the expensive mistake. Working them next on this branch. netcup stays skipped per your note; #4654 stands on its own.

@TomOnTime

Copy link
Copy Markdown
Collaborator

Yes, continue. The TTL change is approved.

The golden-file harness needs a function it can call with a native record
and a DomainConfig. These providers had no such function: the conversion
ran inline in GetZoneRecords, or inline in the method that makes the API
call, so there was nothing to replay recorded data through.

Extracted, one function per provider, no behaviour change:

  akamaiedgedns  nativeToRecords(dc, akarecset)          getRecords
  hetznerv2      nativeToRecords(dc, rrSet, zoneTTL)     GetZoneRecords
  inwx           toRecordConfig(dc, record)              GetZoneRecords
  netlify        toRecordConfig(dc, r)                   GetZoneRecords
  oracle         toRecordConfig(dc, record)              GetZoneRecords
  rwth           toRecordConfig(dc, apiRecord)           getAllRecords

No signature takes an origin or a domain: every one of these functions
used dc.Name only for the API call, which stays in the caller. hetznerv2
is the only one with a third parameter, the zone's default TTL, which the
RRSet does not carry and cannot be derived from dc.

Records that are dropped rather than converted (SOA everywhere, NETLIFY
and NETLIFYv6 in netlify, the locked records in rwth) are now dropped by
returning a nil RecordConfig, and the callers skip nils. That follows what
dynu, exoscale and ovh already do, and it keeps the decision inside the
function, so replaying a recorded zone that contains an SOA produces the
same records the provider produces today.

The counts in the survey posted to DNSControl#4622 were measured with a looser rule
("no free function returning *models.RecordConfig") and were also one too
high: vercel was listed as inlined but already had vercelRecordToRC. Of
the 15 providers that rule flags today, 8 do have a separable converter,
it just returns models.Records or hangs off a type. 7 genuinely had none;
axfrddns is the seventh and is left alone because DNSControl#4358 rewrites that
function onto dnsv2.

Behaviour preservation was checked per provider with a throwaway A/B test
that ran the pre-extraction loop, copied verbatim from 1c629cf, and the
extracted function over the same inputs in the same binary, comparing
label, FQDN, TTL, type, RDATA, metadata and Original. All six agree byte
for byte across every branch, including the error paths. Deleting
rc.Original in oracle, or the NETLIFY skip in netlify, turns the
corresponding comparison red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@shuvamk

shuvamk commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Ask 5 is done, for 6 of the 7 providers that need it. Asks 2, 3 and 4 are not in this push.

The "16" was our number, and it was wrong

That count came from the survey I posted to #4622, so this is me correcting myself, not you. It was measured with a loose rule — "provider declares no free function returning *models.RecordConfig" — and the vercel spot-check in it was simply false: vercelRecordToRC has been at providers/vercel/vercelProvider.go:147 since #4500, with a convert_test.go next to it.

Re-run on release_candidate_v5:

flagged by that loose rule 15 (not 16)
of those, false positives — a separable converter exists, it just returns models.Records or hangs off a type 8: cloudflare hostingde huaweicloud joker mythicbeasts namecheap ns1 softlayer
genuinely nothing callable 7: akamaiedgedns axfrddns hetznerv2 inwx netlify oracle rwth

Extracted (6)

provider function was inline in
akamaiedgedns nativeToRecords(dc, akarecset) getRecords
hetznerv2 nativeToRecords(dc, rrSet, zoneTTL) GetZoneRecords
inwx toRecordConfig(dc, record) GetZoneRecords
netlify toRecordConfig(dc, r) GetZoneRecords
oracle toRecordConfig(dc, record) GetZoneRecords
rwth toRecordConfig(dc, apiRecord) getAllRecords

No signature takes an origin or a domain. All six had domain := dc.Name / zone := dc.Name / zonename := dc.Name in the enclosing function, and in every case that variable feeds only the API call, which stays in the caller. hetznerv2's third parameter is the zone default TTL — hcloud.ZoneRRSet.TTL is *int and falls back to Zone.TTL, which isn't derivable from dc.

axfrddns is deliberately left alone. #4358 rewrites that same function onto dnsv2; extracting it here would collide with that PR.

One judgement call, easy to reverse if you'd rather

Records that are dropped rather than converted — SOA everywhere, NETLIFY/NETLIFYv6 in netlify, locked records in rwth — are now dropped by returning a nil RecordConfig, and the callers skip nils. That follows dynu, exoscale and ovh.

It matters for the golden replay: with the filter left caller-side, replaying a recorded zone containing an SOA emits a golden line the provider never actually produces. I checked that rather than assuming — a throwaway netlify fixture with an SOA and a NETLIFY record replayed through CheckToRC drops both. Say the word and I'll move the filters back to the callers.

Verification

No behaviour change, checked per provider rather than reasoned about: a throwaway A/B test ran the pre-extraction loop, copied verbatim from 1c629cfc, against the extracted function over the same inputs in the same binary, comparing label, FQDN, TTL, type, RDATA, metadata and Original. 13 corpora, all six agree byte for byte including the error paths. Sensitivity checked by perturbing one branch per provider (the akamai SOA gate, hetznerv2's zoneTTL fallback, inwx's PTR dot rule, netlify's NETLIFY skip, oracle's rc.Original, rwth's locked-record skip) — each turned exactly its own comparison red. Those test files are not in the push; they carry a duplicate of the old code.

go test ./... 73 ok / 37 no-test / 0 fail, unchanged from base. go vet, golangci-lint and staticcheck clean. bin/generate-all.sh leaves the tree empty. gofmt -l clean on the six packages both at HEAD and on base, so there's no formatter churn in here.

Not in this push

  • The six are extracted but not enrolled in the golden harness. Enrolling them needs recorded data, and per asks 2 and 3 that data comes from a live integrationTest/ run — netlify is on your credentials list. I'd rather ship no fixture than a hand-written one; a wrong golden is permanent.
  • Asks 2, 3 and 4 untouched. Next up is ask 2, the in-process integrationTest/ run, since 3 and 4 depend on it.
  • pkg/js/parse_tests stays a separate PR, and netcup stays skipped, as you said.

One thing worth flagging for whoever writes the netlify golden: toRecordConfig mutates its *dnsRecord argument (r.Value = dnsutil.Canonical(r.Value), plus the . appended for 3-field SRV). That's pre-existing and I left it alone in a refactor, but it means feeding the same native twice gives different results — it caught me while building the A/B harness.

The golden harness needs recorded data, and that data has to come from a
real provider. "Without executing a shell command" is read here as: our
code must not exec the Go toolchain and scrape its output. The command
stays the one already documented; the recording happens inside the
process it starts.

providergolden.Recorder collects both kinds of harness input:

  - the records a provider is asked to store, written as <name>.records,
    which is what CheckToNative replays;
  - the native record each returned record came from, read from
    RecordConfig.Original and written as <name>.json, which is what
    CheckToRC replays.

providergolden.Record wraps a models.DNSProvider and observes them at
GetZoneRecordsCorrections. That is the one point where both are in the
form the provider's own conversion functions see them: dc.Records has
been downcased, canonicalized and punycoded by zonerecs, and existing
still carries each native record in Original. All four integration tests
are wrapped; the three that call zonerecs.CorrectZoneRecords contribute
data. TestNameserverDots only calls GetNameservers, so it contributes
none. No provider code changes.

integrationTest gains "-record <dir>". Without it nothing is collected,
no file is written and the provider is not wrapped, so a normal run is
unchanged. With it:

  go test -run TestDNSProviders -timeout 1h -failfast -v ./integrationTest \
    -args -verbose -profile CLOUDFLAREAPI -record providers/cloudflare/testdata

Duplicates are discarded and the output is sorted, so a run that revisits
the same records over hundreds of test cases produces a small file that
is the same on every run. Recording BIND this way yields 339 unique
records over 23 types; all 339 lines parse back through the harness's own
parser and re-render byte for byte, and two runs produce identical files.

<name>.json is written only for providers that fill in Original. Of the
21 providers named on the PR, 17 assign it at a9374d8; bind, axfrddns,
mythicbeasts and transip do not, so those record only the .records half.

Original is read where the wrapper sees it, which is not always what the
API sent. providers/netlify canonicalizes a CNAME, MX or NS value in
place before assigning the record to Original, so a native recorded from
it carries a trailing dot the API did not send and a golden replayed from
it never exercises that canonicalization. No wrapper can see the earlier
value: the mutation happens inside the provider's own converter. The
documentation says so and says to check recorded natives against the
API's responses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@shuvamk

shuvamk commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Ask 2 is done. Asks 3 and 4 are not, and I don't think I can do them — see the bottom.

First, my reading of the ask, since it is a reading: "without executing a shell command" I took to mean our code must not exec the Go toolchain and scrape its output. So the command stays the one you already run — you just append -record <dir>:

go test -run TestDNSProviders -timeout 1h -failfast -v ./integrationTest \
  -args -verbose -profile CLOUDFLAREAPI -record providers/cloudflare/testdata

The recording happens inside the process that command starts. If you meant something else — a go:generate target, a subcommand, a Makefile entry — say so and I will redo it.

The hook

providergolden.Record() wraps the provider getProvider() returns and overrides exactly one method:

func (p *recordingProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, existing models.Records) ([]*models.Correction, int, error) {
	p.rec.Observe(dc.Records, existing)
	return p.DNSProvider.GetZoneRecordsCorrections(dc, existing)
}

That is the one place where both harness inputs exist in the form the provider's own converters see them. zonerecs.CorrectZoneRecords has already run FixLegacyRecords, Downcase, CanonicalizeTargets and Punycode over dc.Records, so what I capture is what toNative/toReq is actually handed — hooking GetZoneRecords instead would record the pre-canonicalization version, which is subtly wrong. And existing still carries the native records in Original.

All four tests in integrationTest/ get the wrapper; the three that call zonerecs.CorrectZoneRecords contribute data. TestNameserverDots only calls GetNameservers, so it contributes none. No provider code is touched.

Two files come out, named after the profile:

file is replayed by
cloudflareapi.records every record the tests asked the provider to store CheckToNative
cloudflareapi.json the native each returned record came from, read from RecordConfig.Original CheckToRC

Rename them to match whatever you call the test. Duplicates are dropped and the output is sorted, so a run that hits the same records across hundreds of test cases gives a small file that is identical every time.

One thing you should know before enrolling anything from a recording

Original is not always what the API sent, and no wrapper can fix that. providers/netlify canonicalizes CNAME/MX/NS in place at netlifyProvider.go:131-133 and assigns that same mutated object to rec.Original at :160. Executed:

API returned : {"hostname":"www.example.com","type":"CNAME","ttl":300,"value":"target.example.com"}
-record wrote: {"hostname":"www.example.com","type":"CNAME","ttl":300,"value":"target.example.com."}

Same for MX and NS. A, AAAA, TXT and CAA are untouched. Nothing is corrupted, but the fixture has the canonicalization pre-applied, so a golden replayed from it never exercises the line it exists to pin. The mutation happens inside the provider's own converter, before GetZoneRecords returns, so there is no vantage point outside the provider from which a wrapper could record the earlier value. The fix, if you want one, is provider-side; I have not made it because it is a separate change from this one. The documentation now carries this warning.

Netlify's three-field SRV branch at :144-147 is also non-idempotent: replaying a recorded three-field SRV gives "1 2 sipserver.example.com..", and another dot on each pass after that. I could not establish that Netlify's API ever returns that shape — your own write path sends a single-field target at :265, and a single-field SRV value goes through unchanged. So: a hazard if that branch is reachable from the real API, not a live bug I have seen.

I have not swept the other 16 Original-setting providers for the same pattern. netlify matters because it is on your credentials list and is one of the six I extracted last push, so it is a likely first enrolment.

What I could actually run

BIND is the only provider I can drive, so that is my only live evidence, and it covers the .records half only:

BIND_DOMAIN=example.com go test -count=1 -run TestDNSProviders ./integrationTest/ -args -provider BIND -record <dir>
ok  github.com/DNSControl/dnscontrol/v5/integrationTest  1.101s

339 unique records over 23 types (A AAAA CAA CNAME DHCID DNAME DNSKEY DS HTTPS LOC MX NAPTR NS OPENPGPKEY PTR RP SMIMEA SOA SRV SSHFP SVCB TLSA TXT). Two runs, byte-identical. Fed back through the harness's own parseRecords/formatRecord: all 339 lines parse and re-render byte for byte.

BIND produces no .json, because it does not set Original — so I have no live-provider evidence for the .json half at all. That side is covered by unit tests only. Grepping your 21 providers for assignments to RecordConfig.Original at a9374d89: 17 set it; bind, axfrddns, mythicbeasts and transip do not, so those four record only the .records half. That is a grep of non-test sources, not something I executed.

Tests

Six tests in pkg/providergolden/record_test.go (233 lines; record.go is 127). This is new code rather than a fix, so stashing it only yields a build failure — I mutation-tested it instead, one perturbation per behaviour, suite re-run each time. Baseline 6/6 pass:

mutation goes red
drop the Original == nil guard WritesTheNativeRecordsBehindTheConvertedOnes
write .records when nothing was observed WritesNothingWhenItObservedNothing (+2)
write .json when nothing was observed WritesNothingWhenItObservedNothing (+2)
reverse the output order WritesTheRecordsItObserved
don't de-duplicate records / natives DiscardsDuplicates
hook forgets dc.Records, or doesn't observe RecordObservesTheConversionsAndReturnsWhatTheProviderReturned
swallow the marshal errors in WriteTo ReportsANativeItCannotMarshal

That last test also pins that the wrapper returns the provider's own corrections, count and error unchanged. The mutation pass earned its keep twice: my first draft had a redundant early return in WriteTo that no test could detect, and the error path was unpinned until I added a test that forces a marshal failure.

One side effect worth naming: integrationTest now imports pkg/providergolden, so -update shows up in go test ./integrationTest -args -h. It does nothing there.

Local run

On a9374d89: go build . ok; go test ./... 73 ok / 37 no-test / 0 fail, unchanged from the previous commit on this branch; go test -race ./pkg/providergolden/ ok; go vet, golangci-lint (0 issues) and staticcheck clean; bin/generate-all.sh leaves the tree empty; the six go-checks commands leave a 0-file diff; gofmt -l clean on both touched packages at HEAD and at base; BIND integration passes with and without -record. Merges cleanly onto the current release_candidate_v5 — none of the five commits it gained touch these files.

Asks 3 and 4 — I can build the mechanism, but I cannot produce the data

Both need credentials for accounts I do not have and will not ask you for. What I can honestly deliver is what is in this push: the mechanism, so you can produce the data yourself with one flag on the command you already run.

I am not going to hand-write fixtures to make the list look finished. A wrong golden is permanent, and it is the same failure mode as netcup — the file would look authoritative while certifying whatever I guessed.

If you run -record against any of the 21 and send me the two files, I will do the enrolment and the goldens. AXFRDDNS should wait for #4658 either way, and netlify wants the Original question settled first.

pkg/js/parse_tests still a separate PR; netcup still skipped.

@TomOnTime

Copy link
Copy Markdown
Collaborator

@shuvamk In #4671 i fixed netlify

Please continue.

shuvamk and others added 3 commits August 2, 2026 09:09
Resolves the conflict DNSControl#4671 created with the ask-5 extraction.

DNSControl#4671 ("NETLIFY: toRC should not mutate native record") was written against
the inline conversion loop in GetZoneRecords. 22cc55d had already moved that
loop into toRecordConfig, so the two edits landed on the same lines.

The resolution carries DNSControl#4671's semantics into toRecordConfig verbatim: the
dnsutil.Canonical in-place mutation of r.Value for CNAME/MX/NS and the
three-field-SRV `r.Value += "."` hack are gone, and nrc.Flags{TargetIsFqdnNoDot:
true} is passed on the MX, SRV, CAA and default arms. The strings and
dnsutil imports go, pkg/nrc arrives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…arness

Each file adapts one provider's record conversion functions to CheckToRC /
CheckToNative and names the recorded data after the provider and the function
under test. All 22 tests skip today ("<name> has no recorded data yet"), so
dropping a -record capture into providers/<x>/testdata/ and running
"go test ./providers/<x>/ -update" produces the goldens with no further code.

The adapters are per-provider because the signatures are: cloudflare's
nativeToRecord is a method; ns1's convert, route53's and azure's
nativeToRecords and gandiv5's nativeToRecords already return several records;
gcloud's nativeToRecord takes one rdata string out of a set, so the adapter
loops over set.Rrdatas the way getZoneSets does; digitalocean's and netlify's
toReq return no error; cnr's createRecordString is a method that also needs the
domain; vercel's toVercelCreateRequest needs the domain; luadns's
recordsToNative takes a slice but maps each record independently, so a
one-element slice exercises it faithfully.

Providers of the 21 that are not enrolled here, and why:

  BIND          ParseZoneContents takes a whole zone file, not a record
  MYTHICBEASTS  zoneFileToRecords takes an io.Reader
  AXFRDDNS      no separable converter (DNSControl#4658 rewrites it)
  POWERDNS      toRecordConfig needs the RRset's name/TTL/type, but
                RecordConfig.Original holds only zones.Record{Content,Disabled};
                buildRecordList is the only encoder and it takes a diff2.Change
  NETNOD        same on both counts: Original is
                netnodPrimaryDNS.Record{Content,Disabled}

CNR and TRANSIP get the toNative half only: CNR sets Original to
deleteRecordString(rc), a string rather than the map[string]string toRC reads,
and TRANSIP never sets Original at all, so -record cannot produce a .json for
either.

The set-level encoders are left out because CheckToNative converts one record at
a time: gandiv5's recordsToNative, azuredns's and azureprivatedns's
recordToNativeDiff2, gcloud's mkRRSs and ns1's buildRecord. Each merges a whole
recordset into one native, so calling it with a one-element slice would not
exercise what it is for.

testDomain is "example.com" in every file; it has to match the zone the data
was recorded against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rded zone

Nothing enforces that the domain passed to CheckToRC / CheckToNative is the zone
the fixture was recorded against, and getting it wrong does not fail. Replaying a
fixture recorded from realzone.net through a test that says example.com leaves
every label fully qualified in the golden:

  www.realzone.net 300 IN A 192.0.2.1

LabelFromFQDNNoDot prints "ERROR: ... called WRONG" but returns the name
lowercased rather than shortened, so the test passes, -update writes that golden
and it becomes the baseline. Executed against providers/netlify.

Also drops netlify as the named example from the Original warning: DNSControl#4671 removed
its in-place canonicalization of CNAME, MX and NS values, so the example no
longer holds. The hazard it illustrates is general, so only the example goes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@shuvamk

shuvamk commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Asks 3 and 4, as far as they go without your credentials.

Read this first: the test's domain has to match the zone you record against, and getting it wrong does not fail

This is the one thing that will bite you across 21 -record runs, so it goes at the top rather than in a footnote. testDomain is "example.com" in every file I added. If the fixture was recorded against a different zone, the labels stay fully qualified and the golden is written wrong — green:

www.realzone.net 300 IN A 192.0.2.1
realzone.net 3600 IN MX 10 mail.example.org.

LabelFromFQDNNoDot does print ERROR: LabelFromFQDNNoDot(www.realzone.net) called WRONG while this happens, but it returns the name lowercased rather than shortened, the test passes, -update writes that golden and it becomes the baseline. I executed this against providers/netlify. So: set the constant before you run -update, not after. It is now a warning in §1 of provider-conversion-tests.md.

If you would rather the harness refuse instead of warning — CheckToRC erroring when a produced label still ends in the domain it was given — say so and I will add it. I did not push that unsolicited, since it changes the harness's contract for everyone.

#4671 carried into toRecordConfig, and proved equivalent

Clearing the conflict, netlify's import block auto-merged cleanly while toRecordConfig further down still called strings.Fields and dnsutil.Canonical — the file does not compile until you finish the job by hand. Resolved by taking your semantics verbatim: the in-place dnsutil.Canonical on CNAME/MX/NS is gone, the three-field-SRV r.Value += "." is gone, and nrc.Flags{TargetIsFqdnNoDot: true} is on the MX, SRV, CAA and default arms.

To check I had not changed behaviour, I ran your GetZoneRecords loop body — copied verbatim from b9823fbc — against the resolved toRecordConfig over a 32-record corpus (25 converted, 3 returned nil, 4 errored), comparing Name, NameFQDN, TTL, Type, LineString(), GetRDATA().String(), Metadata, Original, and whether either side mutated the native it was handed. 32/32 agree, including all four error paths.

One perturbation per behaviour, to check the corpus actually discriminates:

perturbation of toRecordConfig probe
drop nrc.Flags from the MX arm red
drop nrc.Flags from the SRV arm red
drop nrc.Flags from the default arm red
restore the dnsutil.Canonical mutation red — flagged as mutating the native, on CNAME/MX/NS
restore the 3-field-SRV += "." red, but only once the corpus had a 3-field SRV value
drop nrc.Flags from the CAA arm green — no input discriminates it

The CAA gap is not a corpus defect. models/record_factory.go:90-93 shows the flag's whole effect is origin = "", so it only changes how a host target is qualified, and CAA RDATA has no host-target field. Our CAA arm is byte-identical to yours, so this is a limit of the probe rather than a risk. issuewild, a trailing-dot value and ";" all fail to move it.

My earlier netlify objection is withdrawn

I said netlify's converter mutated its argument and stored the canonicalised value in Original, so a recorded fixture would arrive pre-canonicalised and the golden would never exercise the line that applies it. #4671 makes that false — the "mutated the native" assertion passes on all 32 records. I have also dropped netlify as the named example from the Original warning in provider-conversion-tests.md; the general hazard is still worth stating, so only the example goes.

That was only ever a netlify finding. The other 16 of your 21 profiles that assign Original have not been swept for the same pattern. I am not implying a survey I did not run.

Ask 4: 16 of the 21 enrolled, 5 that cannot be

One convert_golden_test.go per provider, each adapting that provider's own functions to CheckToRC/CheckToNative. All 22 tests skip today with <name> has no recorded data yet, so nothing is asserted until data lands.

provider CheckToRC CheckToNative
AZURE_DNS nativeToRecords
AZURE_PRIVATE_DNS nativeToRecords
CLOUDFLAREAPI nativeToRecord
CLOUDNS toRc toReq
CNR createRecordString
DIGITALOCEAN toRc toReq
GANDI_V5 nativeToRecords
GCLOUD nativeToRecord
HEDNS recordToRC
LUADNS nativeToRecord recordsToNative
NAMEDOTCOM toRecord
NETLIFY toRecordConfig toReq
NS1 convert
ROUTE53 nativeToRecords
TRANSIP recordToNative
VERCEL vercelRecordToRC toVercelCreateRequest, toVercelUpdateRequest

Not enrolled, with the reason:

  • BINDParseZoneContents takes a whole zone-file blob, not a record. Ironically it is the only one of the 21 I can drive with no credentials.
  • MYTHICBEASTSzoneFileToRecords takes an io.Reader.
  • AXFRDDNS — no separable converter, and AXFRDDNS: convert to codeberg.org/miekg/dns (dnsv2) #4658 is rewriting it.
  • POWERDNStoRecordConfig(dc, r, ttl, name, rtype) needs the enclosing RRset's name, TTL and type, but rc.Original holds only zones.Record{Content, Disabled}, so a recorded .json cannot drive it. The other direction is buildRecordList(change diff2.Change), which is not per-record either.
  • NETNOD — same on both counts; Original is netnodPrimaryDNS.Record{Content, Disabled}.

Two providers get only the toNative half, for the same class of reason: CNR sets rc.Original = deleteRecordString(rc), a string rather than the map[string]string toRC reads; TRANSIP never sets Original at all.

If you want POWERDNS and NETNOD in, pointing rc.Original at the RRset is a one-line change in each converter and I will add it — but that is your call, since Original is provider-visible.

The set-level encoders are deliberately left out, because CheckToNative converts one record at a time and a one-element slice would not exercise the merging they exist for: gandiv5's recordsToNative, azuredns's and azureprivatedns's recordToNativeDiff2, gcloud's mkRRSs and ns1's buildRecord. Each merges a whole recordset into one native — mkRRSs appends every record's RDATA into one Rrdatas, buildRecord calls AddAnswer per record onto one dns.Record — so GCLOUD and NS1 are enrolled for CheckToRC only. luadns's recordsToNative also takes a slice but maps each record independently with no merging, so it is enrolled. If a CheckToNativeSet is worth having, say the word and I will add it for all five.

Ask 3 needs your credentials. What to run.

Per profile:

go test -run TestDNSProviders -timeout 1h -failfast -v ./integrationTest \
  -args -verbose -profile CLOUDFLAREAPI -record providers/cloudflare/testdata

That writes providers/cloudflare/testdata/cloudflareapi.records and cloudflareapi.json. Rename them to the names the tests want: each skip message prints the base name, e.g. cloudflare_nativetorecord has no recorded data yet, so that one wants cloudflare_nativetorecord.json. Then:

go test ./providers/cloudflare/ -update

Besides the domain constant above, one provider-specific thing to know: CLOUDFLAREAPI writes three different shapes into Originalcloudflare.DNSRecord at cloudflareProvider.go:970, plus single-redirect rules at rest.go:385 and worker routes at rest.go:477. They all land in one .json, so that file needs splitting before -update. This one fails loudly rather than silently, which is why it is a note and not a warning; I fed it a mixed fixture and got:

cloudflare_nativetorecord.json: record 1: unparsable "" record received from cloudflare:
anyToTypeNum("") failed: invalid type ""

CLOUDFLAREAPI is the only one that needs this. Of the other 15, thirteen write a single native type into Original, and CNR and TRANSIP produce no .json at all.

Local gate at abbea40b

  • go build ./... ok
  • go test -count=1 ./...75 ok / 35 no test files / 0 FAIL. Baseline with these files removed is 73/37/0; the two that moved are providers/gcloud and providers/netlify, which had no test file at all before.
  • go test -count=1 -run Golden ./providers/... — 22 tests, all SKIP, all has no recorded data yet
  • golangci-lint run ./... 0 issues · staticcheck ./... clean · go vet ./... clean
  • the six go-checks commands leave a 0-file diff
  • BIND_DOMAIN=example.com go test ./integrationTest/ -args -provider BIND ok, and with -record it emits 341 bind.records lines over 23 types, byte-identical across two runs. (Earlier in this PR I quoted 339 for that; 341 is what the current base produces, measured twice.) BIND writes no bind.json — it never fills Original — so the .json side still has no live-provider evidence anywhere in this PR.

To confirm the enrolments are wired and not merely compiling, I hand-wrote a three-record fixture for DIGITALOCEAN (A, MX, CAA) and ran -update. toRc produced

www 300 IN A 192.0.2.1
@ 3600 IN MX 10 mail.example.net.
@ 3600 IN CAA 0 issue "letsencrypt.org"

and toReq produced JSON carrying DO's CAA trailing-dot quirk ("data": "letsencrypt.org."). Deleting that + "." from toReq turns the golden red with exactly that one line in the diff. Restored, fixture deleted, tests back to skipping — nothing in any testdata/ is checked in.

shuvamk and others added 2 commits August 3, 2026 21:12
Resolves the conflict DNSControl#4681 created with the ask-5 extraction.

DNSControl#4681 ("use dc.LabelFromShort() even when not required for safety") changed
`dc.NewRecordConfigParse(rrSet.Name, ...)` to
`dc.NewRecordConfigParse(dc.LabelFromShort(rrSet.Name), ...)` in hetznerv2's
inline conversion loop. 22cc55d had already moved that loop into
nativeToRecords, so the two edits landed on the same lines.

The resolution carries DNSControl#4681's semantics into nativeToRecords verbatim and
keeps the extraction; the net change against release_candidate_v5 is the
extraction alone. It is the only conflict in the 22 commits from b9823fb to
e5dc8f6 (v5.0.0-rc2/3/4); no other file this branch touches was modified
upstream.

`go test ./providers/{packetframe,porkbun,websupport}/ -update` rewrites the
recorded goldens byte-identically on top of rc4, so the dnsv1-to-dnsv2 upgrade
(DNSControl#4683) and the RC.DnsKey*/.Loc*/.Smimea* removal (DNSControl#4686) did not move any
recorded conversion output. No golden is regenerated here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TomOnTime

Copy link
Copy Markdown
Collaborator

I've tested this and here is my feedback:

  • On the command line the path to the testdata directory has to be prefixed with ../ so that the testdata is with the provider's code. For example: go test -run TestDNSProviders -v ./integrationTest -args -verbose -profile VERCEL -record ../providers/vercel/testdata
  • Is it possible for the testdata directory to have a default? The provider should know its directory name. The default should be providers/${providername}/testdata
  • Instead of specifying const testDomain = "example.com" the testDomain should be automatically collected from the environment variable named ${PROVIDER}_DOMAIN for example VERCEL_DOMAIN or NETLIFY_DOMAIN. Only if the env variable is empty should it default to "example.com".
  • it is difficult to match the .json and the .records because neither has a common identifier. How can we improve this?

shuvamk and others added 4 commits August 4, 2026 01:58
…ronment

Addresses three of the four points raised in review.

The documented -record path was relative to integrationTest/ rather than to
the repo root, so the command in provider-conversion-tests.md wrote to
integrationTest/providers/<name>/testdata. Executed with -profile BIND: the
documented shape landed bind.records under integrationTest/.

-record is now a bool, and the destination defaults to the testdata directory
of the package the provider under test is implemented in, derived from the
provider's own type: -profile BIND writes providers/bind/testdata/bind.records.
-recorddir overrides it and implies -record. A relative -recorddir is still
relative to integrationTest/ and needs the ../ prefix, which the documentation
now states and shows. Passing a directory to -record, the old spelling, now
fails naming the new one instead of being silently dropped by flag parsing.

testDomain comes from providergolden.Domain(<PROVIDER>), which reads
$<PROVIDER>_DOMAIN and falls back to "example.com" - the same variable
integrationTest takes its test zone from. Applied to the 16 providers whose
data is not recorded yet. packetframe, porkbun and websupport keep the
literal, because their committed goldens are hand-written against example.com
and would no longer match for anyone who has those variables set.

One consequence of that, executed against a throwaway netlify fixture: a
golden recorded while <PROVIDER>_DOMAIN names a real zone only matches while
that variable still names it, so it does not match in a checkout that leaves
it unset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit claimed that passing a directory to -record "now fails
naming the new one instead of being silently dropped by flag parsing". That
was wrong, and this corrects it. -record is a bool, so the directory is a
positional argument, and Go's flag package stops parsing there:

  -args -verbose -record ../providers/bind/testdata -profile BIND

lost -profile, took getProvider's "No -provider or -profile specified" path
and exited 0 having recorded nothing. The guard meant to catch it sat below
that early return, so it never ran. It is now the first thing getProvider
does, and no longer conditional on -record: integrationTest takes no
positional arguments, so a stray one is always a mistake. The same command
now reports

  unexpected argument "../providers/bind/testdata"; the recording directory
  is set with -recorddir

A relative -recorddir was interpreted by the test binary's own working
directory, so -recorddir providers/bind/testdata wrote to
integrationTest/providers/bind/testdata - the same surprise under a new flag
name, which the previous commit documented rather than fixed. It is now
resolved against the module root, the root TestdataDir already derives, so
the ../ prefix is no longer needed and the note about it is gone.

The package doc and the developer documentation offered
providergolden.Domain("WEBSUPPORT") as the example to copy. websupport is one
of the three providers deliberately left on the literal, and its
convert_test.go already declares const testDomain, so a provider author
following the example there gets a duplicate declaration. The example is
netlify now.

Executed: the misordered command above (fails, naming the argument),
-profile BIND -recorddir providers/bind/testdata (writes to
<repo>/providers/bind/testdata), bare -record (writes to the provider's own
testdata directory), and BIND_DOMAIN still selects the zone under test.
go build, go test -count=1 ./... (76 ok, 0 FAIL), golangci-lint, staticcheck,
go vet and the six CI go-checks commands are clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `CheckToNative` example in provider-conversion-tests.md did not
compile. It passed netlify's `toReq` directly, but that function is
`func(rc *models.RecordConfig) *dnsRecordCreate` and `CheckToNative[N]`
takes `func(rc *models.RecordConfig) (N, error)`:

    vet: in call to providergolden.CheckToNative, type
    func(rc *models.RecordConfig) *dnsRecordCreate of toReq does not
    match func(rc *models.RecordConfig) (N, error) (cannot infer N)

The section is headed "That adapter is the only code you write", and
providers/netlify/convert_golden_test.go wraps `toReq` in a closure for
exactly this reason, so the page contradicted the file it names. The
snippet is now byte-identical to that file, and both Go blocks of the
section compile as written.

`recordingDir` gained a test. The previous commit changed it to resolve
`-recorddir` through `providergolden.ResolveDir`, but only `ResolveDir`
itself was covered: reverting the call site left `go test ./...` green.
With the call site reverted the new test reports

    recordingDir() = "providers/bind/testdata", want an absolute path

Also drops the editorialising clause from the `ResolveDir` comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TomOnTime

TomOnTime commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

@shuvamk

There seems to be a bug or maybe I don't understand how to use this.

When I run the -record command, it captures only toRC (not toReq) and the filename does not include the function name.

go test -run TestDNSProviders -v ./integrationTest -args -verbose -profile CLOUDNS  -record

The log shows...

    helpers_test.go:164: Recorded /Users/tlimoncelli/gitthings/dnscontrol/providers/cloudns/testdata/cloudns.records
    helpers_test.go:164: Recorded /Users/tlimoncelli/gitthings/dnscontrol/providers/cloudns/testdata/cloudns.json

When I run the tests, TestToRcGolden reports "cloudns_torc has no recorded data yet" and TestToReqGolden reports "cloudns_toreq has no recorded data yet"

Here is the full output:

$ go test -v  ./providers/cloudns
=== RUN   TestToRcUsesV3RecordConfig
=== RUN   TestToRcUsesV3RecordConfig/MX
=== RUN   TestToRcUsesV3RecordConfig/SRV
=== RUN   TestToRcUsesV3RecordConfig/CAA
--- PASS: TestToRcUsesV3RecordConfig (0.00s)
    --- PASS: TestToRcUsesV3RecordConfig/MX (0.00s)
    --- PASS: TestToRcUsesV3RecordConfig/SRV (0.00s)
    --- PASS: TestToRcUsesV3RecordConfig/CAA (0.00s)
=== RUN   TestToRcConvertsCloudWRToCloudnsWR
--- PASS: TestToRcConvertsCloudWRToCloudnsWR (0.00s)
=== RUN   TestToRcMX
--- PASS: TestToRcMX (0.00s)
=== RUN   TestToRcGolden
    convert_golden_test.go:13: cloudns_torc has no recorded data yet
--- SKIP: TestToRcGolden (0.00s)
=== RUN   TestToReqGolden
    convert_golden_test.go:21: cloudns_toreq has no recorded data yet
--- SKIP: TestToReqGolden (0.00s)
PASS
ok  	github.com/DNSControl/dnscontrol/v5/providers/cloudns	0.214s

Please fix this and verify that any other provider that was converted has the same fix.

shuvamk and others added 2 commits August 4, 2026 10:25
`-record` wrote `<profile>.records` and `<profile>.json`, but CheckToRC and
CheckToNative read `<name>.json` and `<name>.records` for the name the test
passes, and every enrolled provider named that after the function it wraps.
Nothing lined up, so a fresh recording was invisible: a CLOUDNS run wrote
providers/cloudns/testdata/cloudns.records and cloudns.json, and
providers/cloudns still reported

    --- SKIP: TestToRcGolden: cloudns_torc has no recorded data yet
    --- SKIP: TestToReqGolden: cloudns_toreq has no recorded data yet

The recorder cannot name a file after a conversion function: it observes a
provider, not a function, and it produces exactly one `.records` and one `.json`
per run. vercel is where that bites, with two CheckToNative functions and one
recording. So the two names are separated instead of merged. A recorded input
belongs to the provider and is named after it; the golden belongs to the test
and keeps the name the test passes. vercel's two functions now replay the same
`vercel.records` into two goldens, which is what covering both of them means.

The recording is named after the package the provider is implemented in, the
same reflection that already picks the testdata directory, rather than after
`-profile`. A profile name is chosen by whoever wrote profiles.json, and for
four providers the type does not match the directory either: CLOUDFLAREAPI
lives in providers/cloudflare, GANDI_V5 in providers/gandiv5, and both Azure
types in providers/azuredns and providers/azureprivatedns. The tests take the
same name from the directory they run in, so the two ends agree by construction
and a recording needs no renaming.

The six committed inputs are renamed; their goldens and contents are unchanged.
All 19 enrolled providers now resolve to `testdata/<package>.{json,records}`.

Verified end to end without credentials by recording a BIND run and replaying
it through a throwaway CheckToNative: before, the test skipped with "no
recorded data yet" while the 341-record recording sat in the directory it
reads; after, it read all 341.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TomOnTime

Copy link
Copy Markdown
Collaborator

@shuvamk

I'm still getting the same error:

$ go test -v
=== RUN   TestToRcUsesV3RecordConfig
=== RUN   TestToRcUsesV3RecordConfig/MX
=== RUN   TestToRcUsesV3RecordConfig/SRV
=== RUN   TestToRcUsesV3RecordConfig/CAA
--- PASS: TestToRcUsesV3RecordConfig (0.00s)
    --- PASS: TestToRcUsesV3RecordConfig/MX (0.00s)
    --- PASS: TestToRcUsesV3RecordConfig/SRV (0.00s)
    --- PASS: TestToRcUsesV3RecordConfig/CAA (0.00s)
=== RUN   TestToRcConvertsCloudWRToCloudnsWR
--- PASS: TestToRcConvertsCloudWRToCloudnsWR (0.00s)
=== RUN   TestToRcMX
--- PASS: TestToRcMX (0.00s)
=== RUN   TestToRcGolden
    convert_golden_test.go:13: testdata/cloudns_torc.golden does not exist: run "go test . -update" to record it
--- SKIP: TestToRcGolden (0.00s)
=== RUN   TestToReqGolden
    convert_golden_test.go:21: testdata/cloudns_toreq.golden does not exist: run "go test . -update" to record it
--- SKIP: TestToReqGolden (0.00s)
PASS
ok  	github.com/DNSControl/dnscontrol/v5/providers/cloudns	0.237s

Then I ran -update as the error message explained:

$ cd providers/cloudns
$ go test . -update
ok  	github.com/DNSControl/dnscontrol/v5/providers/cloudns	0.237s

Now the error message is gone:

$ cd ../..
$ go test -v  ./providers/cloudns
=== RUN   TestToRcUsesV3RecordConfig
=== RUN   TestToRcUsesV3RecordConfig/MX
=== RUN   TestToRcUsesV3RecordConfig/SRV
=== RUN   TestToRcUsesV3RecordConfig/CAA
--- PASS: TestToRcUsesV3RecordConfig (0.00s)
    --- PASS: TestToRcUsesV3RecordConfig/MX (0.00s)
    --- PASS: TestToRcUsesV3RecordConfig/SRV (0.00s)
    --- PASS: TestToRcUsesV3RecordConfig/CAA (0.00s)
=== RUN   TestToRcConvertsCloudWRToCloudnsWR
--- PASS: TestToRcConvertsCloudWRToCloudnsWR (0.00s)
=== RUN   TestToRcMX
--- PASS: TestToRcMX (0.00s)
=== RUN   TestToRcGolden
--- PASS: TestToRcGolden (0.00s)
=== RUN   TestToReqGolden
--- PASS: TestToReqGolden (0.00s)
PASS
ok  	github.com/DNSControl/dnscontrol/v5/providers/cloudns	0.241s

However now I'm confused. Does the golden file record everything and -update splits it out to the per-function files?

That seems like an extra step.

Please post a comment explaining if I'm using these tools as expected and make recommendations for how I should use them better or how we can improve the code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment