From 70396dff338dbc6fcb69889022783d8870f692c7 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:24:23 -0400 Subject: [PATCH] Read extension metadata safely --- internal/resources/extension/read.go | 76 +++++++++ internal/resources/extension/read_test.go | 179 ++++++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 internal/resources/extension/read.go create mode 100644 internal/resources/extension/read_test.go diff --git a/internal/resources/extension/read.go b/internal/resources/extension/read.go new file mode 100644 index 0000000..8428088 --- /dev/null +++ b/internal/resources/extension/read.go @@ -0,0 +1,76 @@ +package extension + +import ( + "context" + "fmt" + + "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 extensionReader interface { + GetExtension(context.Context, string, string) (*kernel.ExtensionGetResponse, error) +} + +func readExtension(ctx context.Context, client extensionReader, state extensionModel) (extensionModel, bool, 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 extensionModel{}, false, diags + } + + id, ok := extensionStateID(state, "read", &diags) + if !ok { + return extensionModel{}, false, diags + } + if state.ProjectID.IsUnknown() { + diags.AddAttributeError( + path.Root("project_id"), + "Unknown Kernel Project ID", + "Cannot read a Kernel extension while project_id is unknown in Terraform state.", + ) + return extensionModel{}, false, diags + } + + projectID := state.ProjectID.ValueString() + scope := "the API-key-bound project" + if projectID != "" { + scope = fmt.Sprintf("project %q", projectID) + } + response, err := client.GetExtension(ctx, projectID, id) + if err != nil { + if projectscope.IsNotFound(err) { + return extensionModel{}, true, diags + } + projectscope.AddError(&diags, "Read Kernel Extension", projectID, fmt.Errorf("read extension %q in %s: %w", id, scope, err)) + return extensionModel{}, false, diags + } + if response == nil { + diags.AddError( + "Read Kernel Extension", + fmt.Sprintf("Kernel returned an empty response for extension %q in %s.", id, scope), + ) + return extensionModel{}, false, diags + } + + nextState, flattenDiags := flattenExtensionRead(*response, state) + diags.Append(flattenDiags...) + return nextState, false, diags +} + +func extensionStateID(state extensionModel, operation string, diags *diag.Diagnostics) (string, bool) { + if state.ID.IsNull() || state.ID.IsUnknown() || state.ID.ValueString() == "" { + diags.AddAttributeError( + path.Root("id"), + "Missing Kernel Extension ID", + "Cannot "+operation+" a Kernel extension without a known id in Terraform state.", + ) + return "", false + } + return state.ID.ValueString(), true +} diff --git a/internal/resources/extension/read_test.go b/internal/resources/extension/read_test.go new file mode 100644 index 0000000..26f6eac --- /dev/null +++ b/internal/resources/extension/read_test.go @@ -0,0 +1,179 @@ +package extension + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/terraform-provider-kernel/internal/kernelclient" +) + +var _ extensionReader = kernelclient.Clients{} + +type fakeExtensionReader struct { + get func(context.Context, string, string) (*kernel.ExtensionGetResponse, error) +} + +func (f fakeExtensionReader) GetExtension(ctx context.Context, projectID, id string) (*kernel.ExtensionGetResponse, error) { + if f.get == nil { + return nil, errors.New("unexpected get") + } + return f.get(ctx, projectID, id) +} + +func TestReadExtensionGetsAndFlattensDurableState(t *testing.T) { + t.Parallel() + + checksum := strings.Repeat("a", 64) + prior := extensionModel{ + ID: types.StringValue("extension_123"), + ProjectID: types.StringValue("project_123"), + SourceSHA256: types.StringValue(checksum), + } + var gotProjectID, gotID string + next, removed, diags := readExtension(context.Background(), fakeExtensionReader{ + get: func(ctx context.Context, projectID, id string) (*kernel.ExtensionGetResponse, error) { + gotProjectID, gotID = projectID, id + response := extensionGetResponseForTest(t, `{"id":"extension_123","name":"Extension","checksum":"`+checksum+`"}`) + return &response, nil + }, + }, prior) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if removed { + t.Fatal("removed = true, want false") + } + if gotProjectID != "project_123" || gotID != "extension_123" { + t.Fatalf("GetExtension scope/id = %q/%q, want project_123/extension_123", gotProjectID, gotID) + } + if got, want := next.Name.ValueString(), "Extension"; got != want { + t.Fatalf("name = %q, want %q", got, want) + } + if !next.ProjectID.Equal(prior.ProjectID) { + t.Fatalf("project_id = %v, want %v", next.ProjectID, prior.ProjectID) + } +} + +func TestReadExtensionRemovesOnlyCodedNotFound(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + projectID types.String + err error + wantRemoved bool + wantSummary string + wantDetail string + }{ + "extension not found": { + err: extensionAPIErrorForTest(t, http.StatusNotFound, `{"code":"not_found"}`), + wantRemoved: true, + }, + "project not found": { + projectID: types.StringValue("project_123"), + err: extensionAPIErrorForTest(t, http.StatusNotFound, `{"code":"project_not_found"}`), + wantSummary: "Kernel Project Not Found", + wantDetail: `extension_123`, + }, + "uncoded not found": { + err: extensionAPIErrorForTest(t, http.StatusNotFound, `{}`), + wantSummary: "Read Kernel Extension", + wantDetail: "the API-key-bound project", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, removed, diags := readExtension(context.Background(), fakeExtensionReader{ + get: func(ctx context.Context, projectID, id string) (*kernel.ExtensionGetResponse, error) { + return nil, test.err + }, + }, extensionModel{ID: types.StringValue("extension_123"), ProjectID: test.projectID}) + if removed != test.wantRemoved { + t.Fatalf("removed = %t, want %t", removed, test.wantRemoved) + } + if test.wantSummary == "" { + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + } else if !extensionDiagnosticContains(diags, test.wantSummary, test.wantDetail) { + t.Fatalf("diagnostics = %v, want %q", diags, test.wantSummary) + } + }) + } +} + +func TestReadExtensionRejectsInvalidStateBeforeGet(t *testing.T) { + t.Parallel() + + tests := map[string]extensionModel{ + "missing id": {ID: types.StringNull(), ProjectID: types.StringNull()}, + "unknown id": {ID: types.StringUnknown(), ProjectID: types.StringNull()}, + "empty id": {ID: types.StringValue(""), ProjectID: types.StringNull()}, + "unknown project": {ID: types.StringValue("extension_123"), ProjectID: types.StringUnknown()}, + } + + for name, state := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + called := false + _, removed, diags := readExtension(context.Background(), fakeExtensionReader{ + get: func(ctx context.Context, projectID, id string) (*kernel.ExtensionGetResponse, error) { + called = true + return nil, nil + }, + }, state) + if !diags.HasError() { + t.Fatal("expected invalid-state diagnostic") + } + if removed { + t.Fatal("removed = true, want false") + } + if called { + t.Fatal("GetExtension called for invalid state") + } + }) + } +} + +func TestReadExtensionReportsMissingClientAndEmptyResponse(t *testing.T) { + t.Parallel() + + state := extensionModel{ID: types.StringValue("extension_123"), ProjectID: types.StringNull()} + tests := map[string]struct { + client extensionReader + summary string + detail string + }{ + "missing client": { + summary: "Missing Kernel Client", + }, + "empty response": { + client: fakeExtensionReader{ + get: func(ctx context.Context, projectID, id string) (*kernel.ExtensionGetResponse, error) { + return nil, nil + }, + }, + summary: "Read Kernel Extension", + detail: `extension_123`, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, removed, diags := readExtension(context.Background(), test.client, state) + if removed { + t.Fatal("removed = true, want false") + } + if !extensionDiagnosticContains(diags, test.summary, test.detail) { + t.Fatalf("diagnostics = %v, want %q", diags, test.summary) + } + }) + } +}