From cd65265b59ece0dc4ff87f4dc0f299fad456d0d8 Mon Sep 17 00:00:00 2001 From: Witold Duranek Date: Mon, 27 May 2024 13:37:35 +0200 Subject: [PATCH] feat: add netdata_node_room_member --- CHANGELOG.md | 6 + docs/resources/node_room_member.md | 47 +++ examples/complete/main.tf | 9 + .../netdata_node_room_member/import.sh | 3 + .../netdata_node_room_member/resource.tf | 8 + internal/client/client.go | 1 + internal/client/models.go | 10 + internal/client/node_room_member.go | 113 ++++++ internal/provider/node_room_member.go | 367 ++++++++++++++++++ internal/provider/provider.go | 1 + 10 files changed, 565 insertions(+) create mode 100644 docs/resources/node_room_member.md create mode 100644 examples/resources/netdata_node_room_member/import.sh create mode 100644 examples/resources/netdata_node_room_member/resource.tf create mode 100644 internal/client/node_room_member.go create mode 100644 internal/provider/node_room_member.go diff --git a/CHANGELOG.md b/CHANGELOG.md index ea2a0bc..c2ad1dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.2.0 + +FEATURES: + +- add `netdata_node_room_member` resource + ## 0.1.3 FEATURES: diff --git a/docs/resources/node_room_member.md b/docs/resources/node_room_member.md new file mode 100644 index 0000000..841701c --- /dev/null +++ b/docs/resources/node_room_member.md @@ -0,0 +1,47 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "netdata_node_room_member Resource - terraform-provider-netdata" +subcategory: "" +description: |- + Provides a Netdata Cloud Node Room Member resource. Use this resource to manage node membership to the room in the selected space, only reachable nodes can be added to the room. + This resource is useful in the case of Netdata Streaming and Replication https://learn.netdata.cloud/docs/observability-centralization-points/metrics-centralization-points/ when you want to spread + the Netdata child agents across different rooms because by default all of them end in the same room like the Netdata parent. +--- + +# netdata_node_room_member (Resource) + +Provides a Netdata Cloud Node Room Member resource. Use this resource to manage node membership to the room in the selected space, only reachable nodes can be added to the room. +This resource is useful in the case of [Netdata Streaming and Replication](https://learn.netdata.cloud/docs/observability-centralization-points/metrics-centralization-points/) when you want to spread +the Netdata child agents across different rooms because by default all of them end in the same room like the Netdata parent. + +## Example Usage + +```terraform +resource "netdata_node_room_member" "test" { + space_id = "" + room_id = "" + node_names = [ + "node1", + "node2" + ] +} +``` + + +## Schema + +### Required + +- `node_names` (List of String) List of node names to add to the room +- `room_id` (String) The Room ID of the space +- `space_id` (String) Space ID of the member + +## Import + +Import is supported using the following syntax: + +```shell +#!/bin/sh + +terraform import netdata_node_room_member.test space_id,room_id +``` diff --git a/examples/complete/main.tf b/examples/complete/main.tf index 12d6047..3d8631d 100644 --- a/examples/complete/main.tf +++ b/examples/complete/main.tf @@ -32,6 +32,15 @@ resource "netdata_room_member" "test" { space_member_id = netdata_space_member.test.id } +resource "netdata_node_room_member" "test" { + room_id = netdata_room.test.id + space_id = netdata_space.test.id + node_names = [ + "node1", + "node2" + ] +} + resource "netdata_notification_slack_channel" "test" { name = "slack" diff --git a/examples/resources/netdata_node_room_member/import.sh b/examples/resources/netdata_node_room_member/import.sh new file mode 100644 index 0000000..6b561b4 --- /dev/null +++ b/examples/resources/netdata_node_room_member/import.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +terraform import netdata_node_room_member.test space_id,room_id diff --git a/examples/resources/netdata_node_room_member/resource.tf b/examples/resources/netdata_node_room_member/resource.tf new file mode 100644 index 0000000..d47fbe8 --- /dev/null +++ b/examples/resources/netdata_node_room_member/resource.tf @@ -0,0 +1,8 @@ +resource "netdata_node_room_member" "test" { + space_id = "" + room_id = "" + node_names = [ + "node1", + "node2" + ] +} diff --git a/internal/client/client.go b/internal/client/client.go index a264e0c..4df1194 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -15,6 +15,7 @@ var ( ErrChannelIDRequired = errors.New("channelID is required") ErrRoomIDRequired = errors.New("roomID is required") ErrMemberIDRequired = errors.New("memberID is required") + ErrNodeID = errors.New("nodeID is required") ) type Client struct { diff --git a/internal/client/models.go b/internal/client/models.go index df8a06a..8959aa8 100644 --- a/internal/client/models.go +++ b/internal/client/models.go @@ -72,3 +72,13 @@ type Invitation struct { ID string `json:"id"` Email string `json:"email"` } + +type RoomNodes struct { + Nodes []RoomNode `json:"nodes"` +} + +type RoomNode struct { + NodeID string `json:"nd"` + NodeName string `json:"nm"` + State string `json:"state"` +} diff --git a/internal/client/node_room_member.go b/internal/client/node_room_member.go new file mode 100644 index 0000000..3892101 --- /dev/null +++ b/internal/client/node_room_member.go @@ -0,0 +1,113 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +func (c *Client) GetRoomNodes(spaceID, roomID string) (*RoomNodes, error) { + if spaceID == "" { + return nil, ErrSpaceIDRequired + } + if roomID == "" { + return nil, ErrRoomIDRequired + } + + reqBody := []byte(`{"scope":{"nodes":[]}}`) + + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v3/spaces/%s/rooms/%s/nodes", c.HostURL, spaceID, roomID), bytes.NewReader(reqBody)) + if err != nil { + return nil, err + } + + var roomNodes RoomNodes + + err = c.doRequestUnmarshal(req, &roomNodes) + if err != nil { + return nil, err + } + + return &roomNodes, nil +} + +func (c *Client) GetAllNodes(spaceID string) (*RoomNodes, error) { + if spaceID == "" { + return nil, ErrSpaceIDRequired + } + + allRooms, err := c.GetRooms(spaceID) + if err != nil { + return nil, err + } + + var allNodesRoomID string + for _, room := range *allRooms { + if room.Name == "All nodes" { + allNodesRoomID = room.ID + break + } + } + + roomNodes, err := c.GetRoomNodes(spaceID, allNodesRoomID) + if err != nil { + return nil, err + } + + return roomNodes, nil +} + +func (c *Client) CreateNodeRoomMember(spaceID, roomID, nodeID string) error { + if spaceID == "" { + return ErrSpaceIDRequired + } + if roomID == "" { + return ErrRoomIDRequired + } + if nodeID == "" { + return ErrNodeID + } + + reqBody, err := json.Marshal([]string{nodeID}) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/spaces/%s/rooms/%s/claimed-nodes", c.HostURL, spaceID, roomID), bytes.NewReader(reqBody)) + if err != nil { + return err + } + + _, err = c.doRequest(req) + if err != nil { + return err + } + + return nil + +} + +func (c *Client) DeleteNodeRoomMember(spaceID, roomID, nodeID string) error { + if spaceID == "" { + return ErrSpaceIDRequired + } + if roomID == "" { + return ErrRoomIDRequired + } + if nodeID == "" { + return ErrNodeID + } + + req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/spaces/%s/rooms/%s/claimed-nodes?node_ids=%s", c.HostURL, spaceID, roomID, nodeID), nil) + if err != nil { + return err + } + + _, err = c.doRequest(req) + if err != nil { + return err + } + + return nil +} diff --git a/internal/provider/node_room_member.go b/internal/provider/node_room_member.go new file mode 100644 index 0000000..8105152 --- /dev/null +++ b/internal/provider/node_room_member.go @@ -0,0 +1,367 @@ +package provider + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "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/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/netdata/terraform-provider-netdata/internal/client" +) + +var ( + _ resource.Resource = &roomMemberResource{} + _ resource.ResourceWithConfigure = &roomMemberResource{} +) + +func NewNodeRoomMemberResource() resource.Resource { + return &nodeRoomMemberResource{} +} + +type nodeRoomMemberResource struct { + client *client.Client +} + +type nodeRoomMemberResourceModel struct { + RoomID types.String `tfsdk:"room_id"` + SpaceID types.String `tfsdk:"space_id"` + NodeNames types.List `tfsdk:"node_names"` +} + +func (s *nodeRoomMemberResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_node_room_member" +} + +func (s *nodeRoomMemberResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: ` +Provides a Netdata Cloud Node Room Member resource. Use this resource to manage node membership to the room in the selected space, only reachable nodes can be added to the room. +This resource is useful in the case of [Netdata Streaming and Replication](https://learn.netdata.cloud/docs/observability-centralization-points/metrics-centralization-points/) when you want to spread +the Netdata child agents across different rooms because by default all of them end in the same room like the Netdata parent.`, + Attributes: map[string]schema.Attribute{ + "room_id": schema.StringAttribute{ + Description: "The Room ID of the space", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "space_id": schema.StringAttribute{ + Description: "Space ID of the member", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "node_names": schema.ListAttribute{ + Description: "List of node names to add to the room", + ElementType: types.StringType, + Required: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + }, + }, + } +} + +func (s *nodeRoomMemberResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + s.client = client +} + +func (s *nodeRoomMemberResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan nodeRoomMemberResourceModel + + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, fmt.Sprintf("Creating node room member for space_id/room_id/node_names: %s/%s/%s", plan.SpaceID.ValueString(), plan.RoomID.ValueString(), plan.NodeNames.String())) + + allNodes, err := s.client.GetAllNodes(plan.SpaceID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Error Getting All Nodes", + "err: "+err.Error(), + ) + return + } + + planNodes := make([]types.String, 0, len(plan.NodeNames.Elements())) + diags = plan.NodeNames.ElementsAs(ctx, &planNodes, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // precheck if all nodes exist + for _, planNode := range planNodes { + exist, _ := checkNodeExists(planNode.ValueString(), allNodes, true) + if !exist { + resp.Diagnostics.AddError( + "Error Creating Node Room Member", + fmt.Sprintf("Reachable node %s not found in the space %s", planNode.String(), plan.SpaceID.ValueString()), + ) + return + } + } + + for _, planNode := range planNodes { + _, nodeID := checkNodeExists(planNode.ValueString(), allNodes, true) + err := s.client.CreateNodeRoomMember(plan.SpaceID.ValueString(), plan.RoomID.ValueString(), nodeID) + if err != nil { + resp.Diagnostics.AddError( + "Error Creating Node Room Member", + "err: "+err.Error(), + ) + return + } + } + + plan.RoomID = types.StringValue(plan.RoomID.ValueString()) + plan.SpaceID = types.StringValue(plan.SpaceID.ValueString()) + plan.NodeNames, _ = types.ListValueFrom(ctx, types.StringType, plan.NodeNames) + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (s *nodeRoomMemberResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state nodeRoomMemberResourceModel + + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + nodeRoomMember, err := s.client.GetRoomNodes(state.SpaceID.ValueString(), state.RoomID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Error Getting Node Room Member", + fmt.Sprintf("Could not read node room member for space_id/room_id/node_names: %s/%s/%s err: %v", state.SpaceID.ValueString(), state.RoomID.ValueString(), state.NodeNames.String(), err.Error()), + ) + return + } + + var refreshedItems []string + var foundItems bool + + stateNodes := make([]types.String, 0, len(state.NodeNames.Elements())) + diags = state.NodeNames.ElementsAs(ctx, &stateNodes, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + for _, stateNode := range stateNodes { + exist, _ := checkNodeExists(stateNode.ValueString(), nodeRoomMember, false) + if exist { + refreshedItems = append(refreshedItems, stateNode.ValueString()) + foundItems = true + } + } + + if !foundItems { + resp.State.RemoveResource(ctx) + return + } + + state.NodeNames, _ = types.ListValueFrom(ctx, types.StringType, refreshedItems) + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (s *nodeRoomMemberResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan nodeRoomMemberResourceModel + var state nodeRoomMemberResourceModel + + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + allNodes, err := s.client.GetAllNodes(plan.SpaceID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Error Getting All Nodes", + "err: "+err.Error(), + ) + return + } + + planNodes := make([]types.String, 0, len(plan.NodeNames.Elements())) + diags = plan.NodeNames.ElementsAs(ctx, &planNodes, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // precheck if all nodes exist + for _, planNode := range planNodes { + exist, _ := checkNodeExists(planNode.ValueString(), allNodes, true) + if !exist { + resp.Diagnostics.AddError( + "Error Creating Node Room Member", + fmt.Sprintf("Reachable node %s not found in the space %s", planNode.String(), plan.SpaceID.ValueString()), + ) + return + } + } + + stateNodes := make([]types.String, 0, len(plan.NodeNames.Elements())) + diags = state.NodeNames.ElementsAs(ctx, &stateNodes, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + for _, stateNode := range stateNodes { + foundState := false + for _, planNode := range planNodes { + if stateNode.ValueString() == planNode.ValueString() { + foundState = true + } + } + if !foundState { + exist, nodeID := checkNodeExists(stateNode.ValueString(), allNodes, false) + if exist { + err := s.client.DeleteNodeRoomMember(state.SpaceID.ValueString(), state.RoomID.ValueString(), nodeID) + if err != nil { + resp.Diagnostics.AddError( + "Error Deleting Node Room Member", + "err: "+err.Error(), + ) + return + } + } + } + } + + for _, planNode := range planNodes { + _, nodeID := checkNodeExists(planNode.ValueString(), allNodes, true) + err := s.client.CreateNodeRoomMember(plan.SpaceID.ValueString(), plan.RoomID.ValueString(), nodeID) + if err != nil { + resp.Diagnostics.AddError( + "Error Creating Node Room Member", + "err: "+err.Error(), + ) + return + } + } + + plan.RoomID = types.StringValue(plan.RoomID.ValueString()) + plan.SpaceID = types.StringValue(plan.SpaceID.ValueString()) + plan.NodeNames, _ = types.ListValueFrom(ctx, types.StringType, plan.NodeNames) + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (s *nodeRoomMemberResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state nodeRoomMemberResourceModel + + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + stateNodes := make([]types.String, 0, len(state.NodeNames.Elements())) + diags = state.NodeNames.ElementsAs(ctx, &stateNodes, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + nodeRoomMember, err := s.client.GetRoomNodes(state.SpaceID.ValueString(), state.RoomID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Error Getting Node Room Member", + fmt.Sprintf("Could not read node room member for space_id/room_id/node_names: %s/%s/%s err: %v", state.SpaceID.ValueString(), state.RoomID.ValueString(), state.NodeNames.String(), err.Error()), + ) + return + } + + for _, stateNode := range stateNodes { + exist, nodeID := checkNodeExists(stateNode.ValueString(), nodeRoomMember, false) + if exist { + err := s.client.DeleteNodeRoomMember(state.SpaceID.ValueString(), state.RoomID.ValueString(), nodeID) + if err != nil { + resp.Diagnostics.AddError( + "Error Deleting Node Room Member", + "err: "+err.Error(), + ) + return + } + } + } +} + +func (s *nodeRoomMemberResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, ",") + + if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" { + resp.Diagnostics.AddError( + "Unexpected Import Identifier", + fmt.Sprintf("Expected import identifier with format: space_id,room_id Got: %q", req.ID), + ) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("space_id"), idParts[0])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("room_id"), idParts[1])...) +} + +func checkNodeExists(searchingForNodeName string, nodes *client.RoomNodes, reachableOnly bool) (bool, string) { + for _, node := range nodes.Nodes { + if searchingForNodeName == node.NodeName { + if node.State == "reachable" && reachableOnly { + return true, node.NodeID + } else if !reachableOnly { + return true, node.NodeID + } + } + } + return false, "" +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 96eb70f..d5e68ac 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -95,6 +95,7 @@ func (p *netdataCloudProvider) Resources(ctx context.Context) []func() resource. NewSlackChannelResource, NewDiscordChannelResource, NewPagerdutyChannelResource, + NewNodeRoomMemberResource, } }