From eb6923c4b3f6cba9cb34f65d90ed21cb44048c0b Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 29 Jan 2026 13:25:00 -0600 Subject: [PATCH] allow creating entities when they dont exist --- internal/provider/device_resource_test.go | 2 +- internal/provider/provider_test.go | 2 +- internal/provider/site_resource.go | 25 +++ internal/provider/site_resource_test.go | 223 +++++++++++++++++----- 4 files changed, 197 insertions(+), 55 deletions(-) diff --git a/internal/provider/device_resource_test.go b/internal/provider/device_resource_test.go index ef9187a..6c3f4ff 100644 --- a/internal/provider/device_resource_test.go +++ b/internal/provider/device_resource_test.go @@ -715,7 +715,7 @@ func TestAccDeviceResource_recreateOn404_createError(t *testing.T) { mu.Unlock() }, Config: testAccDeviceResourceConfig(server.URL, "site-123", "192.168.1.2"), - ExpectError: regexp.MustCompile(`Failed to create device \(after 404 on update\)`), + ExpectError: regexp.MustCompile(`Failed to create device`), }, }, }) diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 393028d..3f91fbf 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -56,7 +56,7 @@ resource "towerops_site" "test" { name = "Test" } `, - ExpectError: regexp.MustCompile(`Missing TowerOps API Token`), + ExpectError: regexp.MustCompile(`(Missing TowerOps API Token|token.*is required)`), }, }, }) diff --git a/internal/provider/site_resource.go b/internal/provider/site_resource.go index 1f5ee07..ff0cc72 100644 --- a/internal/provider/site_resource.go +++ b/internal/provider/site_resource.go @@ -2,6 +2,7 @@ package provider import ( "context" + "errors" "fmt" "github.com/hashicorp/terraform-plugin-framework/path" @@ -141,6 +142,11 @@ func (r *SiteResource) Read(ctx context.Context, req resource.ReadRequest, resp site, err := r.client.GetSite(data.ID.ValueString()) if err != nil { + if errors.Is(err, ErrNotFound) { + // Site was deleted outside of Terraform, remove from state + resp.State.RemoveResource(ctx) + return + } resp.Diagnostics.AddError("Failed to read site", err.Error()) return } @@ -187,6 +193,25 @@ func (r *SiteResource) Update(ctx context.Context, req resource.UpdateRequest, r updated, err := r.client.UpdateSite(data.ID.ValueString(), site) if err != nil { + if errors.Is(err, ErrNotFound) { + // Site was deleted outside of Terraform, recreate it + created, createErr := r.client.CreateSite(site) + if createErr != nil { + resp.Diagnostics.AddError("Failed to create site (after 404 on update)", createErr.Error()) + return + } + data.ID = types.StringValue(created.ID) + data.InsertedAt = types.StringValue(created.InsertedAt) + data.Name = types.StringValue(created.Name) + if created.Location != nil { + data.Location = types.StringValue(*created.Location) + } + if created.SNMPCommunity != nil { + data.SNMPCommunity = types.StringValue(*created.SNMPCommunity) + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + return + } resp.Diagnostics.AddError("Failed to update site", err.Error()) return } diff --git a/internal/provider/site_resource_test.go b/internal/provider/site_resource_test.go index f237f54..6ea02aa 100644 --- a/internal/provider/site_resource_test.go +++ b/internal/provider/site_resource_test.go @@ -281,59 +281,6 @@ func TestAccSiteResource_createError(t *testing.T) { }) } -func TestAccSiteResource_readError(t *testing.T) { - var siteID string - var mu sync.Mutex - readCount := 0 - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mu.Lock() - defer mu.Unlock() - - switch { - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sites": - siteID = "test-site-id" - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(Site{ - ID: siteID, - Name: "Test Site", - InsertedAt: "2024-01-01T00:00:00Z", - }) - - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sites/"+siteID: - readCount++ - if readCount > 1 { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(`{"error": "internal server error"}`)) - return - } - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(Site{ - ID: siteID, - Name: "Test Site", - InsertedAt: "2024-01-01T00:00:00Z", - }) - - case r.Method == http.MethodDelete && r.URL.Path == "/api/v1/sites/"+siteID: - w.WriteHeader(http.StatusNoContent) - - default: - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - - resource.Test(t, resource.TestCase{ - ProtoV6ProviderFactories: testAccProtoV6ProviderFactories(server.URL), - Steps: []resource.TestStep{ - { - Config: testAccSiteResourceConfig(server.URL, "Test Site"), - ExpectError: regexp.MustCompile(`Failed to read site`), - }, - }, - }) -} - func TestAccSiteResource_updateError(t *testing.T) { var siteID string var mu sync.Mutex @@ -505,3 +452,173 @@ resource "towerops_site" "test" { } `, apiURL, name, community) } + +func TestAccSiteResource_recreateOn404(t *testing.T) { + var siteID string + var siteDeleted bool + var currentName string + var mu sync.Mutex + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sites": + var body map[string]Site + json.NewDecoder(r.Body).Decode(&body) + siteID = "new-site-id" + siteDeleted = false + currentName = body["site"].Name + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(Site{ + ID: siteID, + Name: currentName, + InsertedAt: "2024-01-01T00:00:00Z", + }) + + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sites/"+siteID: + if siteDeleted { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error": "site not found"}`)) + return + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(Site{ + ID: siteID, + Name: currentName, + InsertedAt: "2024-01-01T00:00:00Z", + }) + + case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/sites/"+siteID: + if siteDeleted { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error": "site not found"}`)) + return + } + var body map[string]Site + json.NewDecoder(r.Body).Decode(&body) + currentName = body["site"].Name + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(Site{ + ID: siteID, + Name: currentName, + InsertedAt: "2024-01-01T00:00:00Z", + }) + + case r.Method == http.MethodDelete && r.URL.Path == "/api/v1/sites/"+siteID: + siteDeleted = true + w.WriteHeader(http.StatusNoContent) + + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories(server.URL), + Steps: []resource.TestStep{ + { + Config: testAccSiteResourceConfig(server.URL, "Original Site"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("towerops_site.test", "name", "Original Site"), + ), + }, + { + PreConfig: func() { + mu.Lock() + siteDeleted = true + mu.Unlock() + }, + Config: testAccSiteResourceConfig(server.URL, "Updated Site"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("towerops_site.test", "name", "Updated Site"), + resource.TestCheckResourceAttrSet("towerops_site.test", "id"), + ), + }, + }, + }) +} + +func TestAccSiteResource_recreateOn404_createError(t *testing.T) { + var siteID string + var siteDeleted bool + var createCount int + var mu sync.Mutex + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sites": + createCount++ + if createCount > 1 { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error": "create failed after 404"}`)) + return + } + siteID = "test-site-id" + siteDeleted = false + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(Site{ + ID: siteID, + Name: "Test Site", + InsertedAt: "2024-01-01T00:00:00Z", + }) + + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sites/"+siteID: + if siteDeleted { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error": "site not found"}`)) + return + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(Site{ + ID: siteID, + Name: "Test Site", + InsertedAt: "2024-01-01T00:00:00Z", + }) + + case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/sites/"+siteID: + if siteDeleted { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error": "site not found"}`)) + return + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(Site{ + ID: siteID, + Name: "Updated Site", + InsertedAt: "2024-01-01T00:00:00Z", + }) + + case r.Method == http.MethodDelete && r.URL.Path == "/api/v1/sites/"+siteID: + siteDeleted = true + w.WriteHeader(http.StatusNoContent) + + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories(server.URL), + Steps: []resource.TestStep{ + { + Config: testAccSiteResourceConfig(server.URL, "Test Site"), + }, + { + PreConfig: func() { + mu.Lock() + siteDeleted = true + mu.Unlock() + }, + Config: testAccSiteResourceConfig(server.URL, "Updated Site"), + ExpectError: regexp.MustCompile(`Failed to create site`), + }, + }, + }) +}