From c2dcd6d1d373ee65cb4864b1f88595d7eeba453d Mon Sep 17 00:00:00 2001
From: Matthew John <matthew@dockstudios.co.uk>
Date: Thu, 27 Mar 2025 12:25:33 +0000
Subject: [PATCH] Add nameserver group resource

---
 .../provider/nameserver_group_resource.go     | 399 ++++++++++++++++++
 internal/provider/network_router_resource.go  |   8 +-
 internal/provider/provider.go                 |   1 +
 3 files changed, 404 insertions(+), 4 deletions(-)
 create mode 100644 internal/provider/nameserver_group_resource.go

diff --git a/internal/provider/nameserver_group_resource.go b/internal/provider/nameserver_group_resource.go
new file mode 100644
index 0000000..b774dc9
--- /dev/null
+++ b/internal/provider/nameserver_group_resource.go
@@ -0,0 +1,399 @@
+package provider
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+
+	"github.com/hashicorp/terraform-plugin-framework/diag"
+	"github.com/hashicorp/terraform-plugin-framework/path"
+	"github.com/hashicorp/terraform-plugin-framework/resource"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
+	"github.com/hashicorp/terraform-plugin-framework/types"
+	netbirdApi "github.com/netbirdio/netbird/management/server/http/api"
+)
+
+// Ensure provider defined types fully satisfy framework interfaces.
+var _ resource.Resource = &NameserverGroupResource{}
+var _ resource.ResourceWithImportState = &NameserverGroupResource{}
+
+func NewNameserverGroupResource() resource.Resource {
+	return &NameserverGroupResource{}
+}
+
+// NameserverGroupResource defines the resource implementation.
+type NameserverGroupResource struct {
+	client *Client
+}
+
+type NameserverResourceModel struct {
+	Ip     types.String `tfsdk:"ip"`
+	NsType types.String `tfsdk:"ns_type"`
+	Port   types.Int32  `tfsdk:"port"`
+}
+
+// ExampleResourceModel describes the resource data model.
+type NameserverGroupResourceModel struct {
+	ID                   types.String              `tfsdk:"id"`
+	Name                 types.String              `tfsdk:"name"`
+	Description          types.String              `tfsdk:"description"`
+	Nameservers          []NameserverResourceModel `tfsdk:"nameservers"`
+	PeerGroups           types.List                `tfsdk:"peer_groups"`
+	Domains              types.List                `tfsdk:"domains"`
+	Primary              types.Bool                `tfsdk:"primary"`
+	SearchDomainsEnabled types.Bool                `tfsdk:"search_domains_enabled"`
+	Enabled              types.Bool                `tfsdk:"enabled"`
+}
+
+func (r *NameserverGroupResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+	resp.TypeName = req.ProviderTypeName + "_nameserver_group"
+}
+
+func (r *NameserverGroupResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
+	resp.Schema = schema.Schema{
+		// This description is used by the documentation generator and the language server.
+		MarkdownDescription: "NameserverGroup resource",
+
+		Attributes: map[string]schema.Attribute{
+			"id": schema.StringAttribute{
+				Computed:            true,
+				MarkdownDescription: "Nameserver Group ID",
+				PlanModifiers: []planmodifier.String{
+					stringplanmodifier.UseStateForUnknown(),
+				},
+			},
+			"name": schema.StringAttribute{
+				MarkdownDescription: "Nameserver group name.",
+				Required:            true,
+			},
+			"description": schema.StringAttribute{
+				MarkdownDescription: "Description of the nameserver group",
+				Optional:            true,
+			},
+			"peer_groups": schema.ListAttribute{
+				ElementType:         types.StringType,
+				MarkdownDescription: "Peer group IDs that defines group of peers that will use this nameserver group",
+				Optional:            true,
+			},
+			"primary": schema.BoolAttribute{
+				MarkdownDescription: "Defines if a nameserver group is primary that resolves all domains. It should be true only if domains list is empty.",
+				Required:            true,
+			},
+			"nameservers": schema.ListNestedAttribute{
+				Required:            true,
+				MarkdownDescription: "Nameserver list",
+				NestedObject: schema.NestedAttributeObject{
+					Attributes: map[string]schema.Attribute{
+						"ip": schema.StringAttribute{
+							MarkdownDescription: "Nameserver IP",
+							Required:            true,
+						},
+						"ns_type": schema.StringAttribute{
+							MarkdownDescription: "Nameserver Type. E.g. `tcp` or `udp`",
+							Required:            true,
+						},
+						"port": schema.Int32Attribute{
+							MarkdownDescription: "Nameserver port",
+							Required:            true,
+						},
+					},
+				},
+			},
+			"domains": schema.ListAttribute{
+				ElementType:         types.StringType,
+				MarkdownDescription: "Match domain list. It should be empty only if primary is true.",
+				Required:            true,
+			},
+			"search_domains_enabled": schema.BoolAttribute{
+				MarkdownDescription: "Search domain status for match domains. It should be true only if domains list is not empty.",
+				Required:            true,
+			},
+
+			"enabled": schema.BoolAttribute{
+				MarkdownDescription: "Nameserver group status",
+				Required:            true,
+			},
+		},
+	}
+}
+
+func (r *NameserverGroupResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
+	// Prevent panic if the provider has not been configured.
+	if req.ProviderData == nil {
+		return
+	}
+
+	client, ok := req.ProviderData.(*Client)
+
+	if !ok {
+		resp.Diagnostics.AddError(
+			"Unexpected Resource Configure Type",
+			fmt.Sprintf("Expected Client, got: %T. Please report this issue to the provider developers.", req.ProviderData),
+		)
+
+		return
+	}
+
+	r.client = client
+}
+
+func (r *NameserverGroupResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
+	var data NameserverGroupResourceModel
+
+	// Read Terraform plan data into the model
+	resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
+
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	apiData, diags := nameserverGroupModelToApiRequest(data)
+	resp.Diagnostics.Append(diags...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+	if apiData == nil {
+		resp.Diagnostics.AddError("nul pointer error", "Got nil pointer to NameserverGroupResourceModel")
+		return
+	}
+
+	requestBody, err := json.Marshal(apiData)
+	if err != nil {
+		resp.Diagnostics.AddError("Error marshaling request body", err.Error())
+		return
+	}
+
+	// Make API request
+	reqURL := fmt.Sprintf("%s/api/dns/nameservers", r.client.BaseUrl)
+	httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(requestBody))
+	if err != nil {
+		resp.Diagnostics.AddError("Error creating request", err.Error())
+		return
+	}
+	httpReq.Header.Set("Content-Type", "application/json")
+
+	responseBody, err := r.client.doRequest(httpReq)
+	if err != nil {
+		resp.Diagnostics.AddError("Error making API request", err.Error())
+		return
+	}
+
+	// Parse response
+	var responseData netbirdApi.NameserverGroup
+	if err := json.Unmarshal(responseBody, &responseData); err != nil {
+		resp.Diagnostics.AddError("Error parsing response", err.Error())
+		return
+	}
+
+	// Assign values from API response
+	data.ID = types.StringValue(responseData.Id)
+
+	diags = r.readNameserverGroupIntoModel(&data)
+	resp.Diagnostics.Append(diags...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	// Save data into Terraform state
+	resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
+}
+
+func (r *NameserverGroupResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
+	var data NameserverGroupResourceModel
+
+	// Read Terraform prior state data into the model
+	resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
+
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	diags := r.readNameserverGroupIntoModel(&data)
+	resp.Diagnostics.Append(diags...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	// Save updated data into Terraform state
+	resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
+}
+
+func (r *NameserverGroupResource) readNameserverGroupIntoModel(data *NameserverGroupResourceModel) diag.Diagnostics {
+	// Update network model
+	// Fetch data from API
+	diags := diag.Diagnostics{}
+	if data == nil {
+		return diags
+	}
+	reqURL := fmt.Sprintf("%s/api/dns/nameservers/%s", r.client.BaseUrl, data.ID.ValueString())
+	httpReq, err := http.NewRequest("GET", reqURL, nil)
+	if err != nil {
+		diags.AddError("Error creating request", err.Error())
+		return diags
+	}
+
+	responseBody, err := r.client.doRequest(httpReq)
+	if err != nil {
+		diags.AddError("Error fetching network", err.Error())
+		return diags
+	}
+	// If not found
+	if responseBody == nil {
+		data.ID = types.StringNull()
+		return diags
+	}
+
+	var responseData netbirdApi.NameserverGroup
+	if err := json.Unmarshal(responseBody, &responseData); err != nil {
+		diags.AddError("Error parsing response", err.Error())
+		return diags
+	}
+
+	data.Name = types.StringValue(responseData.Name)
+	data.Description = nullStringToEmptyString(derefString(&responseData.Description))
+
+	var nameservers []NameserverResourceModel
+	for _, nameserver := range responseData.Nameservers {
+		nameservers = append(nameservers, NameserverResourceModel{
+			Ip:     types.StringValue(nameserver.Ip),
+			NsType: types.StringValue(string(nameserver.NsType)),
+			Port:   types.Int32Value(int32(nameserver.Port)),
+		})
+	}
+	data.Nameservers = nameservers
+
+	data.PeerGroups, diags = convertStringSliceToListValue(responseData.Groups)
+	if diags.HasError() {
+		return diags
+	}
+
+	data.Primary = types.BoolPointerValue(&responseData.Primary)
+
+	data.Domains, diags = convertStringSliceToListValue(responseData.Domains)
+	if diags.HasError() {
+		return diags
+	}
+
+	data.SearchDomainsEnabled = types.BoolPointerValue(&responseData.SearchDomainsEnabled)
+	data.Enabled = types.BoolPointerValue(&responseData.Enabled)
+
+	return diags
+}
+
+func nameserverGroupModelToApiRequest(data NameserverGroupResourceModel) (*netbirdApi.NameserverGroupRequest, diag.Diagnostics) {
+	var diags diag.Diagnostics
+
+	peerGroups, diags := convertListToStringSlice(data.PeerGroups)
+	if diags.HasError() {
+		return nil, diags
+	}
+
+	domains, diags := convertListToStringSlice(data.Domains)
+	if diags.HasError() {
+		return nil, diags
+	}
+
+	var nameservers []netbirdApi.Nameserver
+	for _, nameserverConfig := range data.Nameservers {
+		nameservers = append(nameservers, netbirdApi.Nameserver{
+			Ip:     nameserverConfig.Ip.ValueString(),
+			NsType: netbirdApi.NameserverNsType(nameserverConfig.NsType.ValueString()),
+			Port:   int(nameserverConfig.Port.ValueInt32()),
+		})
+	}
+
+	return &netbirdApi.NameserverGroupRequest{
+		Name:                 data.Name.ValueString(),
+		Description:          data.Description.ValueString(),
+		Nameservers:          nameservers,
+		Groups:               peerGroups,
+		Primary:              data.Primary.ValueBool(),
+		Domains:              domains,
+		SearchDomainsEnabled: data.SearchDomainsEnabled.ValueBool(),
+		Enabled:              data.Enabled.ValueBool(),
+	}, diags
+}
+
+func (r *NameserverGroupResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
+	var data NameserverGroupResourceModel
+
+	// Read Terraform plan data into the model
+	resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
+
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	apiData, diags := nameserverGroupModelToApiRequest(data)
+	resp.Diagnostics.Append(diags...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+	if apiData == nil {
+		resp.Diagnostics.AddError("nul pointer error", "Got nil pointer to NameserverGroupResourceModel")
+		return
+	}
+
+	requestBody, err := json.Marshal(&apiData)
+	if err != nil {
+		resp.Diagnostics.AddError("Error marshaling request body", err.Error())
+		return
+	}
+
+	reqURL := fmt.Sprintf("%s/api/dns/nameservers/%s", r.client.BaseUrl, data.ID.ValueString())
+	httpReq, err := http.NewRequest("PUT", reqURL, bytes.NewBuffer(requestBody))
+	if err != nil {
+		resp.Diagnostics.AddError("Error creating request", err.Error())
+		return
+	}
+	httpReq.Header.Set("Content-Type", "application/json")
+
+	_, err = r.client.doRequest(httpReq)
+	if err != nil {
+		resp.Diagnostics.AddError("Error updating network", err.Error())
+		return
+	}
+
+	diags = r.readNameserverGroupIntoModel(&data)
+	resp.Diagnostics.Append(diags...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	// Save updated data into Terraform state
+	resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
+}
+
+func (r *NameserverGroupResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
+	var data NameserverGroupResourceModel
+
+	// Read Terraform prior state data into the model
+	resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
+
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	reqURL := fmt.Sprintf("%s/api/dns/nameservers/%s", r.client.BaseUrl, data.ID.ValueString())
+	httpReq, err := http.NewRequest("DELETE", reqURL, nil)
+	if err != nil {
+		resp.Diagnostics.AddError("Error creating request", err.Error())
+		return
+	}
+
+	_, err = r.client.doRequest(httpReq)
+	if err != nil {
+		resp.Diagnostics.AddError("Error deleting network", err.Error())
+		return
+	}
+
+	resp.State.RemoveResource(ctx)
+}
+
+func (r *NameserverGroupResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+	resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
+}
diff --git a/internal/provider/network_router_resource.go b/internal/provider/network_router_resource.go
index 09ae89a..08709c8 100644
--- a/internal/provider/network_router_resource.go
+++ b/internal/provider/network_router_resource.go
@@ -164,7 +164,7 @@ func (r *NetworkRouterResource) Create(ctx context.Context, req resource.CreateR
 	// Assign values from API response
 	data.ID = types.StringValue(responseData.Id)
 
-	diags = r.readIntoModel(&data)
+	diags = r.readNetworkRouterIntoModel(&data)
 	resp.Diagnostics.Append(diags...)
 	if resp.Diagnostics.HasError() {
 		return
@@ -184,7 +184,7 @@ func (r *NetworkRouterResource) Read(ctx context.Context, req resource.ReadReque
 		return
 	}
 
-	diags := r.readIntoModel(&data)
+	diags := r.readNetworkRouterIntoModel(&data)
 	resp.Diagnostics.Append(diags...)
 	if resp.Diagnostics.HasError() {
 		return
@@ -194,7 +194,7 @@ func (r *NetworkRouterResource) Read(ctx context.Context, req resource.ReadReque
 	resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
 }
 
-func (r *NetworkRouterResource) readIntoModel(data *NetworkRouterResourceModel) diag.Diagnostics {
+func (r *NetworkRouterResource) readNetworkRouterIntoModel(data *NetworkRouterResourceModel) diag.Diagnostics {
 	// Update network model
 	// Fetch data from API
 	diags := diag.Diagnostics{}
@@ -297,7 +297,7 @@ func (r *NetworkRouterResource) Update(ctx context.Context, req resource.UpdateR
 		return
 	}
 
-	diags = r.readIntoModel(&data)
+	diags = r.readNetworkRouterIntoModel(&data)
 	resp.Diagnostics.Append(diags...)
 	if resp.Diagnostics.HasError() {
 		return
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index b969dff..92a4b65 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -120,6 +120,7 @@ func (p *NetbirdProvider) Resources(ctx context.Context) []func() resource.Resou
 		NewPolicyResource,
 		NewNetworkRouterResource,
 		NewNetworkResourceResource,
+		NewNameserverGroupResource,
 	}
 }
 
-- 
GitLab