From 990dc8fd9daed5f50973a688fb5798ab0c6293d1 Mon Sep 17 00:00:00 2001 From: Georg Pfuetzenreuter Date: Sat, 15 Feb 2025 00:48:56 +0100 Subject: [PATCH 1/5] Correct binary name in gitignore Signed-off-by: Georg Pfuetzenreuter --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index fa8bc85..eb807cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -terraform-provider-dns +terraform-provider-powerdns *.dll *.exe From efa0b10bd6c32153d2acd19a4970aed78f3d33af Mon Sep 17 00:00:00 2001 From: Georg Pfuetzenreuter Date: Wed, 29 Jan 2025 23:25:39 +0100 Subject: [PATCH 2/5] Module functionality This allows zones including records to be defined as individual modules when using this provider as a source. The logic and data structure is mostly taken over from mineiros-io/terraform-aws-route53, allowing the management of PowerDNS zones using a similar representation as Route53 ones. Signed-off-by: Georg Pfuetzenreuter --- main.tf | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ variables.tf | 17 +++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 main.tf create mode 100644 variables.tf diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..600c725 --- /dev/null +++ b/main.tf @@ -0,0 +1,49 @@ +locals { + zones = var.zones + nameservers = var.nameservers +} + +resource "powerdns_zone" "zone" { + for_each = toset(local.zones) + name = each.value + kind = "Native" + nameservers = local.nameservers +} + +locals { + records_expanded = { + for i, record in var.records : join("-", compact([ + lower(record.type), + try(lower(record.name), ""), + ])) => { + type = record.type + name = try(record.name, "") + ttl = try(record.ttl, null) + idx = i + } + } + + records_by_name = { + for product in setproduct(local.zones, keys(local.records_expanded)) : "${product[1]}-${product[0]}" => { + zone = powerdns_zone.zone[product[0]].name + type = local.records_expanded[product[1]].type + name = local.records_expanded[product[1]].name + ttl = local.records_expanded[product[1]].ttl + idx = local.records_expanded[product[1]].idx + } + } + + records = local.records_by_name +} + +resource "powerdns_record" "record" { + for_each = local.records + name = each.value.name == "" ? each.value.zone : join(".", [each.value.name, each.value.zone]) + zone = each.value.zone + type = each.value.type + ttl = each.value.ttl + records = can(var.records[each.value.idx].records) ? [for r in var.records[each.value.idx].records : + each.value.type == "TXT" && length(regexall("(\\\"\\\")", r)) == 0 ? + format("\"%s\"", r) : r + ] : null +} diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..8afcc01 --- /dev/null +++ b/variables.tf @@ -0,0 +1,17 @@ +variable "zones" { + description = "List of zones to configure." + type = list + default = [] +} + +variable "nameservers" { + description = "List of nameservers to configure in the given zones." + type = list + default = [] +} + +variable "records" { + description = "List of records to configure in the given zones." + type = any + default = [] +} From 0f9fe60a7e8ca1d6e7472759b9e19b78831c1af2 Mon Sep 17 00:00:00 2001 From: Georg Pfuetzenreuter Date: Sat, 15 Feb 2025 14:42:42 +0100 Subject: [PATCH 3/5] Add example Signed-off-by: Georg Pfuetzenreuter --- examples/zones/example_com.tf | 30 ++++++++++++++++++++++++++++++ examples/zones/main.tf | 13 +++++++++++++ examples/zones/variables.tf | 6 ++++++ 3 files changed, 49 insertions(+) create mode 100644 examples/zones/example_com.tf create mode 100644 examples/zones/main.tf create mode 100644 examples/zones/variables.tf diff --git a/examples/zones/example_com.tf b/examples/zones/example_com.tf new file mode 100644 index 0000000..624932a --- /dev/null +++ b/examples/zones/example_com.tf @@ -0,0 +1,30 @@ +module "example_com" { + source = "../../terraform-provider-powerdns" + zones = [ + "example.com.", + ] + + nameservers = [ + "ns1.example.com.", + "ns2.example.com.", + ] + + records = [ + { + type = "SOA", + ttl = 300, + records = [ + "ns1.example.com. hostmaster.example.com. 0 10800 3600 604800 3600" + ] + }, + { + name = "www", + type = "AAAA", + ttl = 300, + records = [ + "::1", + ] + } + ] +} + diff --git a/examples/zones/main.tf b/examples/zones/main.tf new file mode 100644 index 0000000..d576688 --- /dev/null +++ b/examples/zones/main.tf @@ -0,0 +1,13 @@ +terraform { + required_providers { + powerdns = { + source = "pan-net/powerdns" + #version = "1.5.0" + } + } +} + +provider "powerdns" { + api_key = var.pdns_api_key + server_url = var.pdns_server_url +} diff --git a/examples/zones/variables.tf b/examples/zones/variables.tf new file mode 100644 index 0000000..eceeb6e --- /dev/null +++ b/examples/zones/variables.tf @@ -0,0 +1,6 @@ +variable "pdns_api_key" { + type = string +} +variable "pdns_server_url" { + type = string +} From 5cdc15bd2f4334727797240835f8953c4bcbf782 Mon Sep 17 00:00:00 2001 From: Georg Pfuetzenreuter Date: Sun, 16 Feb 2025 19:23:53 +0100 Subject: [PATCH 4/5] Avoid nameserver duplication Signed-off-by: Georg Pfuetzenreuter --- examples/zones/example_com.tf | 2 ++ main.tf | 10 +++++++++- variables.tf | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/examples/zones/example_com.tf b/examples/zones/example_com.tf index 624932a..c1effb5 100644 --- a/examples/zones/example_com.tf +++ b/examples/zones/example_com.tf @@ -4,6 +4,8 @@ module "example_com" { "example.com.", ] + # preferably declare NS records instead of this + # https://github.com/pan-net/terraform-provider-powerdns/issues/63 nameservers = [ "ns1.example.com.", "ns2.example.com.", diff --git a/main.tf b/main.tf index 600c725..70d14d6 100644 --- a/main.tf +++ b/main.tf @@ -1,13 +1,21 @@ locals { zones = var.zones nameservers = var.nameservers + nameservers_records = flatten([ for r in var.records : [ for rd in r.records : rd ] if r.type == "NS" ]) } resource "powerdns_zone" "zone" { for_each = toset(local.zones) name = each.value kind = "Native" - nameservers = local.nameservers + nameservers = length(var.nameservers) == 0 ? local.nameservers_records : var.nameservers + lifecycle { + ignore_changes = [ + # https://github.com/pan-net/terraform-provider-powerdns/issues/63 + # users of the module are expected to use NS records for tracking nameservers + nameservers, + ] + } } locals { diff --git a/variables.tf b/variables.tf index 8afcc01..a5c6203 100644 --- a/variables.tf +++ b/variables.tf @@ -5,7 +5,7 @@ variable "zones" { } variable "nameservers" { - description = "List of nameservers to configure in the given zones." + description = "List of nameservers to configure in the given zones (automatically populated from NS records if not specified)." type = list default = [] } From 969df0d206ad37343adbc26b48512288e0e24629 Mon Sep 17 00:00:00 2001 From: Georg Pfuetzenreuter Date: Mon, 17 Feb 2025 01:05:10 +0100 Subject: [PATCH 5/5] Refactor SOA record handling Split the SOA record contents into a more friendly data structure for easier management of the individual options. Avoid automatic serial number management by PowerDNS from conflicting with the serial number passed in the Terraform managed SOA record by only referencing the provided serial number for creation of new zones and by reading the existing serial number from PowerDNS otherwise. Signed-off-by: Georg Pfuetzenreuter --- examples/zones/example_com.tf | 14 +++ main.tf | 71 ++++++++++- powerdns/provider.go | 1 + powerdns/resource_powerdns_record.go | 176 +++++++++++++++++++++++++-- variables.tf | 6 + 5 files changed, 251 insertions(+), 17 deletions(-) diff --git a/examples/zones/example_com.tf b/examples/zones/example_com.tf index c1effb5..6d8a125 100644 --- a/examples/zones/example_com.tf +++ b/examples/zones/example_com.tf @@ -4,6 +4,8 @@ module "example_com" { "example.com.", ] + soa_edit_api = "INCREASE" + # preferably declare NS records instead of this # https://github.com/pan-net/terraform-provider-powerdns/issues/63 nameservers = [ @@ -12,6 +14,18 @@ module "example_com" { ] records = [ + { + type = "SOA" + ttl = 43200 + rname = "admin.opensuse.org." + refresh = 7200 + retry = 600 + expire = 1209600 + minimum = 6400 + # this can be used to set an initial serial number for new zones + # serial number changes to existing zones will be ignored, the user is expected to use SOA-EDIT-API + serial = 1 + }, { type = "SOA", ttl = 300, diff --git a/main.tf b/main.tf index 70d14d6..6a3a22f 100644 --- a/main.tf +++ b/main.tf @@ -1,14 +1,18 @@ locals { zones = var.zones nameservers = var.nameservers - nameservers_records = flatten([ for r in var.records : [ for rd in r.records : rd ] if r.type == "NS" ]) + nameservers_records_data = flatten([ for r in var.records : [ for rd in r.records : rd ] if r.type == "NS" ]) + non_soa_records = [ for r in var.records : r if r.type != "SOA" ] + soa_records = [ for r in var.records : r if r.type == "SOA" ] } resource "powerdns_zone" "zone" { for_each = toset(local.zones) name = each.value kind = "Native" - nameservers = length(var.nameservers) == 0 ? local.nameservers_records : var.nameservers + nameservers = length(var.nameservers) == 0 ? local.nameservers_records_data : var.nameservers + soa_edit_api = var.soa_edit_api + lifecycle { ignore_changes = [ # https://github.com/pan-net/terraform-provider-powerdns/issues/63 @@ -20,13 +24,32 @@ resource "powerdns_zone" "zone" { locals { records_expanded = { - for i, record in var.records : join("-", compact([ + for i, record in local.non_soa_records : join("-", compact([ + lower(record.type), + try(lower(record.name), ""), + ])) => { + type = record.type + name = try(record.name, "") + ttl = try(record.ttl, null) + idx = i + } + } + + records_expanded_soa = { + for i, record in local.soa_records : join("-", compact([ lower(record.type), try(lower(record.name), ""), ])) => { type = record.type name = try(record.name, "") ttl = try(record.ttl, null) + mname = try(record.mname, element(local.nameservers_records_data, 0)), + rname = record.rname, + serial = try(record.serial, 0), + refresh = record.refresh, + retry = record.retry, + expire = record.expire, + minimum = record.minimum, idx = i } } @@ -41,7 +64,47 @@ locals { } } + records_by_name_soa = { + for product in setproduct(local.zones, keys(local.records_expanded_soa)) : "${product[1]}-${product[0]}" => { + zone = powerdns_zone.zone[product[0]].name + type = local.records_expanded_soa[product[1]].type + name = local.records_expanded_soa[product[1]].name + ttl = local.records_expanded_soa[product[1]].ttl + mname = local.records_expanded_soa[product[1]].mname, + rname = local.records_expanded_soa[product[1]].rname, + serial = local.records_expanded_soa[product[1]].serial, + refresh = local.records_expanded_soa[product[1]].refresh, + retry = local.records_expanded_soa[product[1]].retry, + expire = local.records_expanded_soa[product[1]].expire, + minimum = local.records_expanded_soa[product[1]].minimum, + idx = local.records_expanded_soa[product[1]].idx + } + } + records = local.records_by_name + records_soa = local.records_by_name_soa +} + +resource "powerdns_record_soa" "record_soa" { + for_each = local.records_soa + name = each.value.name == "" ? each.value.zone : join(".", [each.value.name, each.value.zone]) + zone = each.value.zone + type = each.value.type + ttl = each.value.ttl + mname = each.value.mname + rname = each.value.rname + serial = each.value.serial + refresh = each.value.refresh + retry = each.value.retry + expire = each.value.expire + minimum = each.value.minimum + + lifecycle { + ignore_changes = [ + serial, + ] + } + } resource "powerdns_record" "record" { @@ -50,7 +113,7 @@ resource "powerdns_record" "record" { zone = each.value.zone type = each.value.type ttl = each.value.ttl - records = can(var.records[each.value.idx].records) ? [for r in var.records[each.value.idx].records : + records = can(local.non_soa_records[each.value.idx].records) ? [for r in local.non_soa_records[each.value.idx].records : each.value.type == "TXT" && length(regexall("(\\\"\\\")", r)) == 0 ? format("\"%s\"", r) : r ] : null diff --git a/powerdns/provider.go b/powerdns/provider.go index d8d2040..d135372 100644 --- a/powerdns/provider.go +++ b/powerdns/provider.go @@ -56,6 +56,7 @@ func Provider() terraform.ResourceProvider { ResourcesMap: map[string]*schema.Resource{ "powerdns_zone": resourcePDNSZone(), "powerdns_record": resourcePDNSRecord(), + "powerdns_record_soa": resourcePDNSRecordSOA(), }, ConfigureFunc: providerConfigure, diff --git a/powerdns/resource_powerdns_record.go b/powerdns/resource_powerdns_record.go index d2be002..0029737 100644 --- a/powerdns/resource_powerdns_record.go +++ b/powerdns/resource_powerdns_record.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "strings" + "strconv" "github.com/hashicorp/terraform-plugin-sdk/helper/schema" ) @@ -61,6 +62,87 @@ func resourcePDNSRecord() *schema.Resource { } } +func resourcePDNSRecordSOA() *schema.Resource { + return &schema.Resource{ + Create: resourcePDNSRecordCreate, + Read: resourcePDNSRecordRead, + Delete: resourcePDNSRecordDelete, + Exists: resourcePDNSRecordExists, + Importer: &schema.ResourceImporter{ + State: resourcePDNSRecordImport, + }, + + Schema: map[string]*schema.Schema{ + "zone": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + + "name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + + "type": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + + "ttl": { + Type: schema.TypeInt, + Required: true, + ForceNew: true, + }, + + "mname": { + Type: schema.TypeString, + Optional: false, + Required: true, + ForceNew: true, + }, + "rname": { + Type: schema.TypeString, + Optional: false, + Required: true, + ForceNew: true, + }, + "serial": { + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + }, + "refresh": { + Type: schema.TypeInt, + Optional: false, + Required: true, + ForceNew: true, + }, + "retry": { + Type: schema.TypeInt, + Optional: false, + Required: true, + ForceNew: true, + }, + "expire": { + Type: schema.TypeInt, + Optional: false, + Required: true, + ForceNew: true, + }, + "minimum": { + Type: schema.TypeInt, + Optional: false, + Required: true, + ForceNew: true, + }, + + }, + } +} + func resourcePDNSRecordCreate(d *schema.ResourceData, meta interface{}) error { client := meta.(*Client) @@ -72,11 +154,17 @@ func resourcePDNSRecordCreate(d *schema.ResourceData, meta interface{}) error { zone := d.Get("zone").(string) ttl := d.Get("ttl").(int) - recs := d.Get("records").(*schema.Set).List() + var recs []interface{} + var recslen int setPtr := false - - if v, ok := d.GetOk("set_ptr"); ok { - setPtr = v.(bool) + if d.Get("type") == "SOA" { + recslen = 1 + } else { + recs = d.Get("records").(*schema.Set).List() + recslen = len(recs) + if v, ok := d.GetOk("set_ptr"); ok { + setPtr = v.(bool) + } } // begin: ValidateFunc @@ -89,20 +177,46 @@ func resourcePDNSRecordCreate(d *schema.ResourceData, meta interface{}) error { log.Printf("[WARN] One or more values in 'records' contain empty '' value(s)") } } - if !(len(recs) > 0) { + if recslen == 0 { return fmt.Errorf("'records' must not be empty") } // end: ValidateFunc - if len(recs) > 0 { - records := make([]Record, 0, len(recs)) - for _, recContent := range recs { + if recslen > 0 { + records := make([]Record, 0, recslen) + + if d.Get("type") == "SOA" { + log.Printf("[DEBUG] Searching existing SOA record at %s => %s", d.Get("zone").(string), d.Get("name").(string)) + soa_records, err := client.ListRecordsInRRSet(d.Get("zone").(string), d.Get("name").(string), "SOA") + if err != nil { + return fmt.Errorf("Failed to fetch old SOA record: %s", err) + } + var serial int + if len(soa_records) > 0 { + serial, err = strconv.Atoi(strings.Fields(soa_records[0].Content)[2]) + if err != nil { + return fmt.Errorf("Failed to parse old serial value in SOA record: %s", err) + } + } else { + serial = d.Get("serial").(int) + } + log.Printf("[DEBUG] Set serial number to %d", serial) + records = append(records, Record{Name: rrSet.Name, Type: rrSet.Type, TTL: ttl, - Content: recContent.(string), + Content: fmt.Sprintf("%s %s %d %d %d %d %d", d.Get("mname"), d.Get("rname"), serial, d.Get("refresh"), d.Get("retry"), d.Get("expire"), d.Get("minimum")), SetPtr: setPtr}) + } else { + for _, recContent := range recs { + records = append(records, + Record{Name: rrSet.Name, + Type: rrSet.Type, + TTL: ttl, + Content: recContent.(string), + SetPtr: setPtr}) + } } rrSet.Records = records @@ -132,12 +246,48 @@ func resourcePDNSRecordRead(d *schema.ResourceData, meta interface{}) error { } recs := make([]string, 0, len(records)) - for _, r := range records { - recs = append(recs, r.Content) + if d.Get("type") == "SOA" { + rsplit := strings.Fields(records[0].Content) + d.Set("mname", rsplit[0]) + d.Set("rname", rsplit[1]) + + serial, err := strconv.Atoi(rsplit[2]) + if err != nil { + return fmt.Errorf("Failed to parse serial value in SOA record: %s", err) + } + d.Set("serial", serial) + + refresh, err := strconv.Atoi(rsplit[3]) + if err != nil { + return fmt.Errorf("Failed to parse refresh value in SOA record: %s", err) + } + d.Set("refresh", refresh) + + retry, err := strconv.Atoi(rsplit[4]) + if err != nil { + return fmt.Errorf("Failed to parse retry value in SOA record: %s", err) + } + d.Set("retry", retry) + + expire, err := strconv.Atoi(rsplit[5]) + if err != nil { + return fmt.Errorf("Failed to parse expire value in SOA record: %s", err) + } + d.Set("expire", expire) + + minimum, err := strconv.Atoi(rsplit[6]) + if err != nil { + return fmt.Errorf("Failed to parse minimum value in SOA record: %s", err) + } + d.Set("minimum", minimum) + } else { + for _, r := range records { + recs = append(recs, r.Content) + } + d.Set("records", recs) } - d.Set("records", recs) - if len(records) > 0 { + if len(records) > 0 || d.Get("Type") == "SOA" { d.Set("ttl", records[0].TTL) } diff --git a/variables.tf b/variables.tf index a5c6203..ec146a0 100644 --- a/variables.tf +++ b/variables.tf @@ -15,3 +15,9 @@ variable "records" { type = any default = [] } + +variable "soa_edit_api" { + description = "SOA-EDIT-API metadata to configure in the given zones." + type = string + default = "INCREMENT" +}