From 70c7a5cfbb1a68c88930d4b6629dd8dd37129c55 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 10 Mar 2026 13:32:56 -0500 Subject: [PATCH] feat: allow setting organization name via Terraform - Make 'name' field optional and computed in organization resource - Include name in Create and Update operations when provided - Name can only be changed by organization owners (enforced by API) - Matches API capability to update organization name - Backwards compatible: name remains optional Example usage: resource "towerops_organization" "main" { name = "My Organization" use_sites = true } --- internal/provider/client.go | 44 +++++ internal/provider/client_test.go | 18 +- internal/provider/device_resource_test.go | 38 ++-- internal/provider/organization_resource.go | 171 ++++++++++++++++++ .../provider/organization_resource_test.go | 139 ++++++++++++++ internal/provider/provider.go | 1 + 6 files changed, 384 insertions(+), 27 deletions(-) create mode 100644 internal/provider/organization_resource.go create mode 100644 internal/provider/organization_resource_test.go diff --git a/internal/provider/client.go b/internal/provider/client.go index 83e640d..da9402f 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -68,6 +68,19 @@ type Device struct { InsertedAt string `json:"inserted_at,omitempty"` } +// Organization represents a TowerOps organization. +type Organization struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Slug string `json:"slug,omitempty"` + UseSites bool `json:"use_sites"` +} + +// organizationResponse wraps the API response for organization endpoints. +type organizationResponse struct { + Data Organization `json:"data"` +} + // APIError represents an error response from the API. type APIError struct { Error string `json:"error,omitempty"` @@ -228,3 +241,34 @@ func (c *Client) DeleteDevice(id string) error { _, err := c.doRequest(http.MethodDelete, "/api/v1/devices/"+id, nil) return err } + +// GetOrganization retrieves the current organization settings. +func (c *Client) GetOrganization() (*Organization, error) { + respBody, err := c.doRequest(http.MethodGet, "/api/v1/organization", nil) + if err != nil { + return nil, err + } + + var result organizationResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &result.Data, nil +} + +// UpdateOrganization updates the current organization settings. +func (c *Client) UpdateOrganization(org Organization) (*Organization, error) { + body := map[string]Organization{"organization": org} + respBody, err := c.doRequest(http.MethodPatch, "/api/v1/organization", body) + if err != nil { + return nil, err + } + + var result organizationResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &result.Data, nil +} diff --git a/internal/provider/client_test.go b/internal/provider/client_test.go index bcab6db..676ba6d 100644 --- a/internal/provider/client_test.go +++ b/internal/provider/client_test.go @@ -7,6 +7,8 @@ import ( "testing" ) +func strPtr(s string) *string { return &s } + func TestClient_ErrNotFound(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) @@ -59,8 +61,8 @@ func TestClient_GetDevice_Success(t *testing.T) { if device.ID != "device-123" { t.Errorf("expected ID device-123, got %s", device.ID) } - if device.SiteID != "site-456" { - t.Errorf("expected SiteID site-456, got %s", device.SiteID) + if device.SiteID == nil || *device.SiteID != "site-456" { + t.Errorf("expected SiteID site-456, got %v", device.SiteID) } if device.IPAddress != "192.168.1.1" { t.Errorf("expected IPAddress 192.168.1.1, got %s", device.IPAddress) @@ -92,7 +94,7 @@ func TestClient_CreateDevice_Success(t *testing.T) { client := NewClient("test-token", server.URL) device := Device{ - SiteID: "site-456", + SiteID: strPtr("site-456"), IPAddress: "192.168.1.100", } @@ -129,7 +131,7 @@ func TestClient_UpdateDevice_Success(t *testing.T) { client := NewClient("test-token", server.URL) device := Device{ - SiteID: "site-456", + SiteID: strPtr("site-456"), IPAddress: "192.168.1.200", } @@ -153,7 +155,7 @@ func TestClient_UpdateDevice_NotFound(t *testing.T) { client := NewClient("test-token", server.URL) device := Device{ - SiteID: "site-456", + SiteID: strPtr("site-456"), IPAddress: "192.168.1.200", } @@ -218,7 +220,7 @@ func TestClient_ValidationError(t *testing.T) { client := NewClient("test-token", server.URL) device := Device{ - SiteID: "site-456", + SiteID: strPtr("site-456"), IPAddress: "invalid", } @@ -521,7 +523,7 @@ func TestClient_CreateDevice_InvalidJSON(t *testing.T) { client := NewClient("test-token", server.URL) - device := Device{SiteID: "site-123", IPAddress: "192.168.1.1"} + device := Device{SiteID: strPtr("site-123"), IPAddress: "192.168.1.1"} _, err := client.CreateDevice(device) if err == nil { t.Fatal("expected error for invalid JSON, got nil") @@ -537,7 +539,7 @@ func TestClient_UpdateDevice_InvalidJSON(t *testing.T) { client := NewClient("test-token", server.URL) - device := Device{SiteID: "site-123", IPAddress: "192.168.1.1"} + device := Device{SiteID: strPtr("site-123"), IPAddress: "192.168.1.1"} _, err := client.UpdateDevice("device-123", device) if err == nil { t.Fatal("expected error for invalid JSON, got nil") diff --git a/internal/provider/device_resource_test.go b/internal/provider/device_resource_test.go index 6c3f4ff..f8b31b0 100644 --- a/internal/provider/device_resource_test.go +++ b/internal/provider/device_resource_test.go @@ -29,7 +29,7 @@ func TestAccDeviceResource_basic(t *testing.T) { w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(Device{ ID: deviceID, - SiteID: "site-123", + SiteID: strPtr("site-123"), Name: &name, IPAddress: "192.168.1.1", MonitoringEnabled: &monitoringEnabled, @@ -42,7 +42,7 @@ func TestAccDeviceResource_basic(t *testing.T) { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(Device{ ID: deviceID, - SiteID: "site-123", + SiteID: strPtr("site-123"), Name: &name, IPAddress: "192.168.1.1", MonitoringEnabled: &monitoringEnabled, @@ -95,7 +95,7 @@ func TestAccDeviceResource_withAllAttributes(t *testing.T) { w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(Device{ ID: deviceID, - SiteID: "site-123", + SiteID: strPtr("site-123"), Name: &name, IPAddress: "10.0.0.1", Description: &description, @@ -111,7 +111,7 @@ func TestAccDeviceResource_withAllAttributes(t *testing.T) { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(Device{ ID: deviceID, - SiteID: "site-123", + SiteID: strPtr("site-123"), Name: &name, IPAddress: "10.0.0.1", Description: &description, @@ -170,7 +170,7 @@ func TestAccDeviceResource_update(t *testing.T) { w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(Device{ ID: deviceID, - SiteID: "site-123", + SiteID: strPtr("site-123"), Name: &name, IPAddress: currentIP, MonitoringEnabled: &monitoringEnabled, @@ -183,7 +183,7 @@ func TestAccDeviceResource_update(t *testing.T) { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(Device{ ID: deviceID, - SiteID: "site-123", + SiteID: strPtr("site-123"), Name: &name, IPAddress: currentIP, MonitoringEnabled: &monitoringEnabled, @@ -199,7 +199,7 @@ func TestAccDeviceResource_update(t *testing.T) { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(Device{ ID: deviceID, - SiteID: "site-123", + SiteID: strPtr("site-123"), Name: &name, IPAddress: currentIP, MonitoringEnabled: &monitoringEnabled, @@ -260,7 +260,7 @@ func TestAccDeviceResource_recreateOn404(t *testing.T) { w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(Device{ ID: deviceID, - SiteID: "site-123", + SiteID: strPtr("site-123"), Name: &name, IPAddress: currentIP, MonitoringEnabled: &monitoringEnabled, @@ -280,7 +280,7 @@ func TestAccDeviceResource_recreateOn404(t *testing.T) { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(Device{ ID: deviceID, - SiteID: "site-123", + SiteID: strPtr("site-123"), Name: &name, IPAddress: currentIP, MonitoringEnabled: &monitoringEnabled, @@ -303,7 +303,7 @@ func TestAccDeviceResource_recreateOn404(t *testing.T) { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(Device{ ID: deviceID, - SiteID: "site-123", + SiteID: strPtr("site-123"), Name: &name, IPAddress: currentIP, MonitoringEnabled: &monitoringEnabled, @@ -367,7 +367,7 @@ func TestAccDeviceResource_importState(t *testing.T) { w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(Device{ ID: deviceID, - SiteID: "site-123", + SiteID: strPtr("site-123"), Name: &name, IPAddress: "192.168.1.1", MonitoringEnabled: &monitoringEnabled, @@ -382,7 +382,7 @@ func TestAccDeviceResource_importState(t *testing.T) { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(Device{ ID: "imported-device-id", - SiteID: "site-123", + SiteID: strPtr("site-123"), Name: &name, IPAddress: "192.168.1.1", MonitoringEnabled: &monitoringEnabled, @@ -491,7 +491,7 @@ func TestAccDeviceResource_updateError(t *testing.T) { w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(Device{ ID: deviceID, - SiteID: "site-123", + SiteID: strPtr("site-123"), Name: &name, IPAddress: "192.168.1.1", MonitoringEnabled: &monitoringEnabled, @@ -506,7 +506,7 @@ func TestAccDeviceResource_updateError(t *testing.T) { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(Device{ ID: deviceID, - SiteID: "site-123", + SiteID: strPtr("site-123"), Name: &name, IPAddress: "192.168.1.1", MonitoringEnabled: &monitoringEnabled, @@ -562,7 +562,7 @@ func TestAccDeviceResource_deleteError(t *testing.T) { w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(Device{ ID: deviceID, - SiteID: "site-123", + SiteID: strPtr("site-123"), Name: &name, IPAddress: "192.168.1.1", MonitoringEnabled: &monitoringEnabled, @@ -577,7 +577,7 @@ func TestAccDeviceResource_deleteError(t *testing.T) { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(Device{ ID: deviceID, - SiteID: "site-123", + SiteID: strPtr("site-123"), Name: &name, IPAddress: "192.168.1.1", MonitoringEnabled: &monitoringEnabled, @@ -642,7 +642,7 @@ func TestAccDeviceResource_recreateOn404_createError(t *testing.T) { w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(Device{ ID: deviceID, - SiteID: "site-123", + SiteID: strPtr("site-123"), Name: &name, IPAddress: "192.168.1.1", MonitoringEnabled: &monitoringEnabled, @@ -662,7 +662,7 @@ func TestAccDeviceResource_recreateOn404_createError(t *testing.T) { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(Device{ ID: deviceID, - SiteID: "site-123", + SiteID: strPtr("site-123"), Name: &name, IPAddress: "192.168.1.1", MonitoringEnabled: &monitoringEnabled, @@ -682,7 +682,7 @@ func TestAccDeviceResource_recreateOn404_createError(t *testing.T) { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(Device{ ID: deviceID, - SiteID: "site-123", + SiteID: strPtr("site-123"), Name: &name, IPAddress: "192.168.1.2", MonitoringEnabled: &monitoringEnabled, diff --git a/internal/provider/organization_resource.go b/internal/provider/organization_resource.go new file mode 100644 index 0000000..0f95777 --- /dev/null +++ b/internal/provider/organization_resource.go @@ -0,0 +1,171 @@ +package provider + +import ( + "context" + "fmt" + + "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" +) + +var _ resource.Resource = &OrganizationResource{} + +// OrganizationResource manages organization settings. +type OrganizationResource struct { + client *Client +} + +// OrganizationResourceModel describes the resource data model. +type OrganizationResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Slug types.String `tfsdk:"slug"` + UseSites types.Bool `tfsdk:"use_sites"` +} + +// NewOrganizationResource creates a new organization resource. +func NewOrganizationResource() resource.Resource { + return &OrganizationResource{} +} + +func (r *OrganizationResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_organization" +} + +func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages organization settings for the organization associated with the API token. There is exactly one organization per token, so this resource manages settings rather than creating or deleting organizations.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The unique identifier of the organization.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the organization. Can only be set by organization owners.", + Optional: true, + Computed: true, + }, + "slug": schema.StringAttribute{ + Description: "The URL-friendly slug of the organization.", + Computed: true, + }, + "use_sites": schema.BoolAttribute{ + Description: "Whether the organization uses sites to group devices. When true, devices are organized under sites. When false, devices belong directly to the organization.", + Required: true, + }, + }, + } +} + +func (r *OrganizationResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + 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", req.ProviderData), + ) + return + } + + r.client = client +} + +func (r *OrganizationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data OrganizationResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + org := Organization{ + UseSites: data.UseSites.ValueBool(), + } + + // Include name if provided + if !data.Name.IsNull() && !data.Name.IsUnknown() { + org.Name = data.Name.ValueString() + } + + updated, err := r.client.UpdateOrganization(org) + if err != nil { + resp.Diagnostics.AddError("Failed to update organization", err.Error()) + return + } + + data.ID = types.StringValue(updated.ID) + data.Name = types.StringValue(updated.Name) + data.Slug = types.StringValue(updated.Slug) + data.UseSites = types.BoolValue(updated.UseSites) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *OrganizationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data OrganizationResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + org, err := r.client.GetOrganization() + if err != nil { + resp.Diagnostics.AddError("Failed to read organization", err.Error()) + return + } + + data.ID = types.StringValue(org.ID) + data.Name = types.StringValue(org.Name) + data.Slug = types.StringValue(org.Slug) + data.UseSites = types.BoolValue(org.UseSites) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *OrganizationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data OrganizationResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + org := Organization{ + UseSites: data.UseSites.ValueBool(), + } + + // Include name if provided + if !data.Name.IsNull() && !data.Name.IsUnknown() { + org.Name = data.Name.ValueString() + } + + updated, err := r.client.UpdateOrganization(org) + if err != nil { + resp.Diagnostics.AddError("Failed to update organization", err.Error()) + return + } + + data.ID = types.StringValue(updated.ID) + data.Name = types.StringValue(updated.Name) + data.Slug = types.StringValue(updated.Slug) + data.UseSites = types.BoolValue(updated.UseSites) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *OrganizationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Organizations cannot be deleted via the API. When this resource is + // removed from Terraform config, we simply remove it from state. + // The organization continues to exist in TowerOps. +} diff --git a/internal/provider/organization_resource_test.go b/internal/provider/organization_resource_test.go new file mode 100644 index 0000000..bc586a4 --- /dev/null +++ b/internal/provider/organization_resource_test.go @@ -0,0 +1,139 @@ +package provider + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccOrganizationResource_basic(t *testing.T) { + var mu sync.Mutex + currentUseSites := false + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organization": + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": Organization{ + ID: "org-123", + Name: "Test ISP", + Slug: "test-isp", + UseSites: currentUseSites, + }, + }) + + case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/organization": + var body map[string]Organization + json.NewDecoder(r.Body).Decode(&body) + currentUseSites = body["organization"].UseSites + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": Organization{ + ID: "org-123", + Name: "Test ISP", + Slug: "test-isp", + UseSites: currentUseSites, + }, + }) + + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories(server.URL), + Steps: []resource.TestStep{ + { + Config: testAccOrganizationResourceConfig(server.URL, true), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("towerops_organization.settings", "use_sites", "true"), + resource.TestCheckResourceAttr("towerops_organization.settings", "name", "Test ISP"), + resource.TestCheckResourceAttr("towerops_organization.settings", "slug", "test-isp"), + ), + }, + }, + }) +} + +func TestAccOrganizationResource_update(t *testing.T) { + var mu sync.Mutex + currentUseSites := false + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organization": + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": Organization{ + ID: "org-123", + Name: "Test ISP", + Slug: "test-isp", + UseSites: currentUseSites, + }, + }) + + case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/organization": + var body map[string]Organization + json.NewDecoder(r.Body).Decode(&body) + currentUseSites = body["organization"].UseSites + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": Organization{ + ID: "org-123", + Name: "Test ISP", + Slug: "test-isp", + UseSites: currentUseSites, + }, + }) + + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories(server.URL), + Steps: []resource.TestStep{ + { + Config: testAccOrganizationResourceConfig(server.URL, true), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("towerops_organization.settings", "use_sites", "true"), + ), + }, + { + Config: testAccOrganizationResourceConfig(server.URL, false), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("towerops_organization.settings", "use_sites", "false"), + ), + }, + }, + }) +} + +func testAccOrganizationResourceConfig(apiURL string, useSites bool) string { + return fmt.Sprintf(` +provider "towerops" { + token = "test-token" + api_url = %q +} + +resource "towerops_organization" "settings" { + use_sites = %t +} +`, apiURL, useSites) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 99ca25c..6d42503 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -86,6 +86,7 @@ func (p *ToweropsProvider) Configure(ctx context.Context, req provider.Configure func (p *ToweropsProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ + NewOrganizationResource, NewSiteResource, NewDeviceResource, }