Skip to content

Commit 7804ed7

Browse files
authored
Add support for multiple TXT RDATA (#368)
* Add support for multiple TXT RDATA * Remove zone from test as it doesn't resolve the error * Change to using an extra answer field * Minor change to changelog * Add doc
1 parent cd8731a commit 7804ed7

5 files changed

Lines changed: 225 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## 2.8.0 (Unreleased)
2+
ENHANCEMENTS
3+
* Add a new record answer field to specify multiple parts directly
4+
BUGFIX
5+
* Fix zones not specifying networks not being created as managed
6+
17
## 2.7.3 (November 25, 2025)
28
BUGFIX
39
* Fix zone create with empty networks field

ns1/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import (
1919
)
2020

2121
var (
22-
clientVersion = "2.7.3"
22+
clientVersion = "2.8.0"
2323
providerUserAgent = "tf-ns1" + "/" + clientVersion
2424
defaultRetryMax = 3
2525
)

ns1/resource_record.go

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,15 @@ It is suggested to migrate to a regular "answers" block. Using Terraform 0.12+,
122122
Elem: &schema.Resource{
123123
Schema: map[string]*schema.Schema{
124124
"answer": {
125-
Type: schema.TypeString,
126-
Optional: true,
125+
Type: schema.TypeString,
126+
Optional: true,
127+
Description: "A single RDATA. This field cannot be set together with `answer_parts`",
128+
},
129+
"answer_parts": {
130+
Type: schema.TypeList,
131+
Optional: true,
132+
Elem: &schema.Schema{Type: schema.TypeString},
133+
Description: "A list of individual RDATA fields. This field cannot be set together with `answer`",
127134
},
128135
"region": {
129136
Type: schema.TypeString,
@@ -310,8 +317,10 @@ func recordToResourceData(d *schema.ResourceData, r *dns.Record) error {
310317
if len(r.Answers) > 0 {
311318
ans := make([]map[string]interface{}, 0)
312319
log.Printf("Got back from ns1 answers: %+v", r.Answers)
313-
for _, answer := range r.Answers {
314-
ans = append(ans, answerToMap(*answer))
320+
321+
stateAnswers := d.Get("answers").([]interface{})
322+
for i, answer := range r.Answers {
323+
ans = append(ans, answerToMap(*answer, i, stateAnswers, r.Type))
315324
}
316325
log.Printf("Setting answers %+v", ans)
317326
err := d.Set("answers", ans)
@@ -355,9 +364,28 @@ func recordMapValueToString(configMap map[string]interface{}) map[string]interfa
355364
return config
356365
}
357366

358-
func answerToMap(a dns.Answer) map[string]interface{} {
367+
// answerToMap converts an answer to a terraform state map
368+
func answerToMap(a dns.Answer, index int, stateAnswers []any, rType string) map[string]any {
359369
m := make(map[string]interface{})
360-
m["answer"] = strings.Join(a.Rdata, " ")
370+
371+
// decide whether to use "answer" or "answer_parts" based on the current state, preferring the first if not available
372+
useAnswerParts := false
373+
if index < len(stateAnswers) {
374+
if existingAnswer, ok := stateAnswers[index].(map[string]any); ok {
375+
if answerParts, ok := existingAnswer["answer_parts"].([]any); ok && len(answerParts) > 0 {
376+
useAnswerParts = true
377+
}
378+
}
379+
} else if rType == "TXT" && len(a.Rdata) > 1 {
380+
useAnswerParts = true
381+
}
382+
383+
if useAnswerParts {
384+
m["answer_parts"] = a.Rdata
385+
} else {
386+
m["answer"] = strings.Join(a.Rdata, " ")
387+
}
388+
361389
if a.RegionName != "" {
362390
m["region"] = a.RegionName
363391
}
@@ -394,14 +422,37 @@ func resourceDataToRecord(r *dns.Record, d *schema.ResourceData) error {
394422
answer := answerRaw.(map[string]interface{})
395423
var a *dns.Answer
396424

397-
v := answer["answer"].(string)
398-
switch d.Get("type") {
399-
case "TXT", "SPF":
400-
a = dns.NewTXTAnswer(v)
401-
case "CAA":
402-
a = dns.NewAnswer(strings.SplitN(v, " ", 3))
403-
default:
404-
a = dns.NewAnswer(strings.Split(v, " "))
425+
// check for mutual exclusivity between answer and answer_parts
426+
hasAnswer := false
427+
hasAnswerParts := false
428+
if v, ok := answer["answer"].(string); ok && v != "" {
429+
hasAnswer = true
430+
}
431+
if answerParts, ok := answer["answer_parts"].([]interface{}); ok && len(answerParts) > 0 {
432+
hasAnswerParts = true
433+
}
434+
if hasAnswer && hasAnswerParts {
435+
return errors.New("cannot specify both 'answer' and 'answer_parts' in the same answers block")
436+
}
437+
438+
if hasAnswerParts {
439+
answerParts := answer["answer_parts"].([]interface{})
440+
parts := make([]string, len(answerParts))
441+
for i, part := range answerParts {
442+
parts[i] = part.(string)
443+
}
444+
a = dns.NewAnswer(parts)
445+
446+
} else {
447+
v := answer["answer"].(string)
448+
switch d.Get("type") {
449+
case "TXT", "SPF":
450+
a = dns.NewTXTAnswer(v)
451+
case "CAA":
452+
a = dns.NewAnswer(strings.SplitN(v, " ", 3))
453+
default:
454+
a = dns.NewAnswer(strings.Split(v, " "))
455+
}
405456
}
406457

407458
if v, ok := answer["region"]; ok {

ns1/resource_record_test.go

Lines changed: 151 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,7 @@ func TestAccRecord_CAA(t *testing.T) {
506506
Config: testAccRecordCAA(rString),
507507
Check: resource.ComposeTestCheckFunc(
508508
testAccCheckRecordExists("ns1_record.caa", &record),
509-
testAccCheckRecordDomain(&record, zoneName),
509+
testAccCheckRecordDomain(&record, "caa."+zoneName),
510510
testAccCheckRecordTTL(&record, 3600),
511511
testAccCheckRecordUseClientSubnet(&record, true),
512512
testAccCheckRecordAnswerRdata(
@@ -520,7 +520,7 @@ func TestAccRecord_CAA(t *testing.T) {
520520
{
521521
ResourceName: "ns1_record.caa",
522522
ImportState: true,
523-
ImportStateId: fmt.Sprintf("%[1]s/%[1]s/CAA", zoneName),
523+
ImportStateId: fmt.Sprintf("%s/%s/CAA", zoneName, "caa."+zoneName),
524524
ImportStateVerify: true,
525525
},
526526
{
@@ -642,8 +642,9 @@ func TestAccRecord_validationError(t *testing.T) {
642642
ExpectError: regexp.MustCompile(`(?s)(Error: (zone|domain) has an invalid (leading|trailing) "\.", got: .*){4}`),
643643
},
644644
{
645-
Config: testAccRecordNoAnswers(rString),
646-
ExpectError: regexp.MustCompile(`Invalid body`),
645+
Config: testAccRecordNoAnswers(rString),
646+
// seems to have changed, was: ExpectError: regexp.MustCompile(`Invalid body`),
647+
ExpectError: regexp.MustCompile(`404 zone not found`),
647648
},
648649
},
649650
})
@@ -1113,6 +1114,70 @@ func TestAccRecord_Link(t *testing.T) {
11131114
})
11141115
}
11151116

1117+
func TestAccRecord_TXT(t *testing.T) {
1118+
var record dns.Record
1119+
rString := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum)
1120+
zoneName := fmt.Sprintf("terraform-test-%s.io", rString)
1121+
domainName := fmt.Sprintf("txt-test.%s", zoneName)
1122+
1123+
resource.Test(t, resource.TestCase{
1124+
PreCheck: func() { testAccPreCheck(t) },
1125+
Providers: testAccProviders,
1126+
CheckDestroy: testAccCheckRecordDestroy,
1127+
Steps: []resource.TestStep{
1128+
{
1129+
Config: testAccRecordTXTWithBothFields(rString),
1130+
ExpectError: regexp.MustCompile("cannot specify both 'answer' and 'answer_parts'"),
1131+
},
1132+
{
1133+
Config: testAccRecordTXTWithAnswerParts(rString),
1134+
Check: resource.ComposeTestCheckFunc(
1135+
testAccCheckRecordExists("ns1_record.txt_test", &record),
1136+
testAccCheckRecordDomain(&record, domainName),
1137+
testAccCheckRecordTTL(&record, 300),
1138+
testAccCheckRecordAnswerRdata(
1139+
t, &record, 0, []string{"foo", "bar", "qux"},
1140+
),
1141+
),
1142+
},
1143+
{
1144+
Config: testAccRecordTXTWithAnswer(rString),
1145+
Check: resource.ComposeTestCheckFunc(
1146+
testAccCheckRecordExists("ns1_record.txt_test", &record),
1147+
testAccCheckRecordDomain(&record, domainName),
1148+
testAccCheckRecordTTL(&record, 300),
1149+
testAccCheckRecordAnswerRdata(
1150+
t, &record, 0, []string{"foo bar qux"},
1151+
),
1152+
),
1153+
},
1154+
{
1155+
ResourceName: "ns1_record.txt_test",
1156+
ImportState: true,
1157+
ImportStateId: fmt.Sprintf("%s/%s/TXT", zoneName, domainName),
1158+
ImportStateVerify: true,
1159+
},
1160+
{
1161+
Config: testAccRecordTXTWithAnswerPartsUpdated(rString),
1162+
Check: resource.ComposeTestCheckFunc(
1163+
testAccCheckRecordExists("ns1_record.txt_test", &record),
1164+
testAccCheckRecordDomain(&record, domainName),
1165+
testAccCheckRecordTTL(&record, 300),
1166+
testAccCheckRecordAnswerRdata(
1167+
t, &record, 0, []string{"foo", "bar", "baz", "qux"},
1168+
),
1169+
),
1170+
},
1171+
{
1172+
ResourceName: "ns1_record.txt_test",
1173+
ImportState: true,
1174+
ImportStateId: fmt.Sprintf("%s/%s/TXT", zoneName, domainName),
1175+
ImportStateVerify: true,
1176+
},
1177+
},
1178+
})
1179+
}
1180+
11161181
func testAccCheckRecordExists(n string, record *dns.Record) resource.TestCheckFunc {
11171182
return func(s *terraform.State) error {
11181183
rs, ok := s.RootModule().Resources[n]
@@ -1717,11 +1782,11 @@ resource "ns1_record" "caa" {
17171782
domain = "caa.${ns1_zone.test.zone}"
17181783
type = "CAA"
17191784
ttl = 3600
1720-
1785+
17211786
answers {
17221787
answer = "0 issue \"letsencrypt.org\""
17231788
}
1724-
1789+
17251790
answers {
17261791
answer = "0 issuewild \";\""
17271792
}
@@ -1745,7 +1810,7 @@ resource "ns1_record" "apl" {
17451810
answers {
17461811
answer = "1:127.0.0.0/16"
17471812
}
1748-
1813+
17491814
}
17501815
`, zone, zone)
17511816
}
@@ -2062,13 +2127,13 @@ func testAccRecordOPENPGPKEY(rString string) string {
20622127
resource "ns1_zone" "test" {
20632128
zone = "terraform-test-%s.io"
20642129
}
2065-
2130+
20662131
resource "ns1_record" "openpgpkey" {
20672132
zone = ns1_zone.test.zone
20682133
domain = "openpgpkey.${ns1_zone.test.zone}"
20692134
type = "OPENPGPKEY"
20702135
ttl = 3600
2071-
2136+
20722137
answers {
20732138
answer = "abba"
20742139
}
@@ -2208,6 +2273,83 @@ resource "ns1_zone" "test" {
22082273
`, rString)
22092274
}
22102275

2276+
func testAccRecordTXTWithAnswerParts(rString string) string {
2277+
return fmt.Sprintf(`
2278+
resource "ns1_record" "txt_test" {
2279+
zone = "${ns1_zone.test.zone}"
2280+
domain = "txt-test.${ns1_zone.test.zone}"
2281+
type = "TXT"
2282+
ttl = 300
2283+
use_client_subnet = "true"
2284+
answers {
2285+
answer_parts = ["foo", "bar", "qux"]
2286+
}
2287+
}
2288+
2289+
resource "ns1_zone" "test" {
2290+
zone = "terraform-test-%s.io"
2291+
}
2292+
`, rString)
2293+
}
2294+
2295+
func testAccRecordTXTWithAnswer(rString string) string {
2296+
return fmt.Sprintf(`
2297+
resource "ns1_record" "txt_test" {
2298+
zone = "${ns1_zone.test.zone}"
2299+
domain = "txt-test.${ns1_zone.test.zone}"
2300+
type = "TXT"
2301+
ttl = 300
2302+
use_client_subnet = "true"
2303+
answers {
2304+
answer = "foo bar qux"
2305+
}
2306+
}
2307+
2308+
resource "ns1_zone" "test" {
2309+
zone = "terraform-test-%s.io"
2310+
}
2311+
`, rString)
2312+
}
2313+
2314+
func testAccRecordTXTWithAnswerPartsUpdated(rString string) string {
2315+
return fmt.Sprintf(`
2316+
resource "ns1_record" "txt_test" {
2317+
zone = "${ns1_zone.test.zone}"
2318+
domain = "txt-test.${ns1_zone.test.zone}"
2319+
type = "TXT"
2320+
ttl = 300
2321+
use_client_subnet = "true"
2322+
answers {
2323+
answer_parts = ["foo", "bar", "baz", "qux"]
2324+
}
2325+
}
2326+
2327+
resource "ns1_zone" "test" {
2328+
zone = "terraform-test-%s.io"
2329+
}
2330+
`, rString)
2331+
}
2332+
2333+
func testAccRecordTXTWithBothFields(rString string) string {
2334+
return fmt.Sprintf(`
2335+
resource "ns1_record" "txt_test" {
2336+
zone = "${ns1_zone.test.zone}"
2337+
domain = "txt-test.${ns1_zone.test.zone}"
2338+
type = "TXT"
2339+
ttl = 300
2340+
use_client_subnet = "true"
2341+
answers {
2342+
answer = "foo bar qux"
2343+
answer_parts = ["foo", "bar", "qux"]
2344+
}
2345+
}
2346+
2347+
resource "ns1_zone" "test" {
2348+
zone = "terraform-test-%s.io"
2349+
}
2350+
`, rString)
2351+
}
2352+
22112353
func TestRegionsMetaDiffSuppress(t *testing.T) {
22122354
metaKeys := []string{"georegion", "country", "us_state", "ca_province"}
22132355

website/docs/r/record.html.markdown

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ The following arguments are supported:
179179

180180
answer = "v=DKIM1; k=rsa; p=XXXXXXXX"
181181

182+
Optionally, the individual parts of the answer can be expressed as a list in the field `answer_parts`.
183+
Only one of `answer` or `answer_parts` can be specified.
182184

183185
* `region` - (Optional) The region (Answer Group really) that this answer
184186
belongs to. This should be one of the names specified in `regions`. Only a

0 commit comments

Comments
 (0)