diff --git a/internal/resources/extension/delete.go b/internal/resources/extension/delete.go new file mode 100644 index 0000000..d239d3d --- /dev/null +++ b/internal/resources/extension/delete.go @@ -0,0 +1,69 @@ +package extension + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/terraform-provider-kernel/internal/projectscope" +) + +type extensionDeleter interface { + DeleteExtension(context.Context, string, string) error +} + +func deleteExtension(ctx context.Context, client extensionDeleter, state extensionModel) diag.Diagnostics { + var diags diag.Diagnostics + if client == nil { + diags.AddError("Missing Kernel Client", "The Kernel provider was not configured before using the extension resource.") + return diags + } + + id, ok := extensionStateID(state, "delete", &diags) + if !ok { + return diags + } + if state.ProjectID.IsUnknown() { + diags.AddAttributeError(path.Root("project_id"), "Unknown Kernel Project ID", "Cannot delete a Kernel extension while project_id is unknown in Terraform state.") + return diags + } + + projectID := state.ProjectID.ValueString() + if err := client.DeleteExtension(ctx, projectID, id); err != nil { + if projectscope.IsNotFound(err) { + return diags + } + if extensionDeleteInUse(err) { + diags.AddError( + "Delete Kernel Extension", + fmt.Sprintf("Kernel refused to delete extension %q because one or more browser pools reference it. Remove the extension from those durable browser pool configurations, then retry. Terraform will not mutate browser pools or running browsers implicitly.", id), + ) + return diags + } + projectscope.AddError(&diags, "Delete Kernel Extension", projectID, fmt.Errorf("delete extension %q: %w", id, err)) + } + return diags +} + +func extensionDeleteInUse(err error) bool { + var apiError *kernel.Error + if !errors.As(err, &apiError) || apiError.StatusCode != http.StatusBadRequest { + return false + } + + var body struct { + Code string `json:"code"` + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if json.Unmarshal([]byte(apiError.RawJSON()), &body) != nil { + return false + } + return body.Code == "resource_in_use" || body.Error.Code == "resource_in_use" +} diff --git a/internal/resources/extension/delete_test.go b/internal/resources/extension/delete_test.go new file mode 100644 index 0000000..2710097 --- /dev/null +++ b/internal/resources/extension/delete_test.go @@ -0,0 +1,112 @@ +package extension + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/kernel/terraform-provider-kernel/internal/kernelclient" +) + +var _ extensionDeleter = kernelclient.Clients{} + +type fakeExtensionDeleter struct { + delete func(context.Context, string, string) error +} + +func (f fakeExtensionDeleter) DeleteExtension(ctx context.Context, projectID, id string) error { + if f.delete == nil { + return errors.New("unexpected delete") + } + return f.delete(ctx, projectID, id) +} + +func TestDeleteExtensionUsesStateIdentityAndScope(t *testing.T) { + t.Parallel() + + var gotProjectID, gotID string + diags := deleteExtension(context.Background(), fakeExtensionDeleter{ + delete: func(ctx context.Context, projectID, id string) error { + gotProjectID, gotID = projectID, id + return nil + }, + }, extensionModel{ID: types.StringValue("extension_123"), ProjectID: types.StringValue("project_123")}) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if gotProjectID != "project_123" || gotID != "extension_123" { + t.Fatalf("DeleteExtension scope/id = %q/%q, want project_123/extension_123", gotProjectID, gotID) + } +} + +func TestDeleteExtensionTreatsCodedNotFoundAsSuccess(t *testing.T) { + t.Parallel() + + diags := deleteExtension(context.Background(), fakeExtensionDeleter{ + delete: func(ctx context.Context, projectID, id string) error { + return extensionAPIErrorForTest(t, http.StatusNotFound, `{"code":"not_found"}`) + }, + }, extensionModel{ID: types.StringValue("extension_123"), ProjectID: types.StringNull()}) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } +} + +func TestDeleteExtensionExplainsDurablePoolDependency(t *testing.T) { + t.Parallel() + + diags := deleteExtension(context.Background(), fakeExtensionDeleter{ + delete: func(ctx context.Context, projectID, id string) error { + return extensionAPIErrorForTest(t, http.StatusBadRequest, `{"code":"resource_in_use"}`) + }, + }, extensionModel{ID: types.StringValue("extension_123"), ProjectID: types.StringNull()}) + if !extensionDiagnosticContains(diags, "Delete Kernel Extension", "Remove the extension from those durable browser pool configurations") { + t.Fatalf("diagnostics = %v, want browser pool dependency guidance", diags) + } +} + +func TestDeleteExtensionDoesNotMisclassifyGenericErrors(t *testing.T) { + t.Parallel() + + tests := map[string]error{ + "generic bad request": extensionAPIErrorForTest(t, http.StatusBadRequest, `{}`), + "generic not found": extensionAPIErrorForTest(t, http.StatusNotFound, `{}`), + "transport failure": errors.New("connection reset"), + } + for name, err := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + diags := deleteExtension(context.Background(), fakeExtensionDeleter{ + delete: func(ctx context.Context, projectID, id string) error { return err }, + }, extensionModel{ID: types.StringValue("extension_123"), ProjectID: types.StringNull()}) + if !extensionDiagnosticContains(diags, "Delete Kernel Extension", "extension_123") { + t.Fatalf("diagnostics = %v, want generic delete error with ID", diags) + } + }) + } +} + +func TestDeleteExtensionRejectsInvalidStateBeforeCall(t *testing.T) { + t.Parallel() + + for name, state := range map[string]extensionModel{ + "missing id": {ID: types.StringNull(), ProjectID: types.StringNull()}, + "unknown project": {ID: types.StringValue("extension_123"), ProjectID: types.StringUnknown()}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + called := false + diags := deleteExtension(context.Background(), fakeExtensionDeleter{ + delete: func(ctx context.Context, projectID, id string) error { called = true; return nil }, + }, state) + if !diags.HasError() { + t.Fatal("expected invalid state diagnostic") + } + if called { + t.Fatal("DeleteExtension called for invalid state") + } + }) + } +}