From 073a4b884105e8dab5ff822f626f1914e52827e3 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 04:19:25 -0400 Subject: [PATCH] Register the durable extension resource Assemble the reviewed extension lifecycle helpers behind a Terraform Plugin Framework resource, require local archive inputs only for create or replacement, and publish generated schema documentation. This makes the durable extension surface usable while keeping runtime operations out of Terraform. --- docs/resources/extension.md | 29 +++ internal/provider/provider.go | 6 +- internal/provider/provider_test.go | 6 +- .../extension/framework_resource_test.go | 82 +++++++ internal/resources/extension/modify_plan.go | 61 +++++ .../resources/extension/modify_plan_test.go | 217 ++++++++++++++++++ internal/resources/extension/resource.go | 85 +++++++ 7 files changed, 481 insertions(+), 5 deletions(-) create mode 100644 docs/resources/extension.md create mode 100644 internal/resources/extension/framework_resource_test.go create mode 100644 internal/resources/extension/modify_plan.go create mode 100644 internal/resources/extension/modify_plan_test.go create mode 100644 internal/resources/extension/resource.go diff --git a/docs/resources/extension.md b/docs/resources/extension.md new file mode 100644 index 0000000..a5711c6 --- /dev/null +++ b/docs/resources/extension.md @@ -0,0 +1,29 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "kernel_extension Resource - Kernel" +subcategory: "" +description: |- + Kernel uploaded extension durable configuration. +--- + +# kernel_extension (Resource) + +Kernel uploaded extension durable configuration. + + + + +## Schema + +### Optional + +> **NOTE**: [Write-only arguments](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments) are supported in Terraform 1.11 and later. + +- `name` (String) Optional extension name. Must be unique within the project. Adding or changing a configured name replaces the extension; omitting it preserves the remote name because the API cannot clear a name. +- `project_id` (String) Project this extension belongs to. Defaults to the provider `project_id` when unset; when neither is set, the API key's project binding determines the project. Adding or changing it replaces the extension. +- `source_path` (String, [Write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments)) Local path to the extension ZIP. Required when creating or replacing the extension and never stored in Terraform plan or state artifacts. Requires Terraform 1.11 or later. +- `source_sha256` (String) Lowercase hexadecimal SHA-256 checksum of the exact extension ZIP bytes. Configure with `filesha256(source_path)`. Adding or changing it replaces the extension. + +### Read-Only + +- `id` (String) Unique extension identifier. diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 5e3a9ee..b9ed3dc 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -7,12 +7,13 @@ import ( "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/kernel/terraform-provider-kernel/internal/datasources/extension" + extensiondatasource "github.com/kernel/terraform-provider-kernel/internal/datasources/extension" "github.com/kernel/terraform-provider-kernel/internal/datasources/profile" projectdatasource "github.com/kernel/terraform-provider-kernel/internal/datasources/project" "github.com/kernel/terraform-provider-kernel/internal/datasources/proxy" "github.com/kernel/terraform-provider-kernel/internal/kernelclient" "github.com/kernel/terraform-provider-kernel/internal/resources/browserpool" + extensionresource "github.com/kernel/terraform-provider-kernel/internal/resources/extension" projectresource "github.com/kernel/terraform-provider-kernel/internal/resources/project" ) @@ -82,6 +83,7 @@ func (p *kernelProvider) Configure(ctx context.Context, req provider.ConfigureRe func (p *kernelProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ browserpool.NewResource, + extensionresource.NewResource, projectresource.NewResource, } } @@ -91,6 +93,6 @@ func (p *kernelProvider) DataSources(ctx context.Context) []func() datasource.Da projectdatasource.NewDataSource, profile.NewDataSource, proxy.NewDataSource, - extension.NewDataSource, + extensiondatasource.NewDataSource, } } diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index a3fdd0d..12d5a4d 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -34,8 +34,8 @@ func TestProviderRegistersResources(t *testing.T) { p := provider.New("test")() resources := p.Resources(context.Background()) - if len(resources) != 2 { - t.Fatalf("Resources length = %d, want 2", len(resources)) + if len(resources) != 3 { + t.Fatalf("Resources length = %d, want 3", len(resources)) } got := make(map[string]bool, len(resources)) @@ -49,7 +49,7 @@ func TestProviderRegistersResources(t *testing.T) { got[resp.TypeName] = true } - for _, want := range []string{"kernel_browser_pool", "kernel_project"} { + for _, want := range []string{"kernel_browser_pool", "kernel_extension", "kernel_project"} { if !got[want] { t.Fatalf("missing resource %s; got %v", want, got) } diff --git a/internal/resources/extension/framework_resource_test.go b/internal/resources/extension/framework_resource_test.go new file mode 100644 index 0000000..ae27e8a --- /dev/null +++ b/internal/resources/extension/framework_resource_test.go @@ -0,0 +1,82 @@ +package extension + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/kernel/terraform-provider-kernel/internal/kernelclient" +) + +var _ extensionClient = kernelclient.Clients{} + +func TestExtensionResourceMetadataAndSchema(t *testing.T) { + t.Parallel() + + r := NewResource() + var metadata resource.MetadataResponse + r.Metadata(context.Background(), resource.MetadataRequest{ProviderTypeName: "kernel"}, &metadata) + if metadata.TypeName != "kernel_extension" { + t.Fatalf("type name = %q, want kernel_extension", metadata.TypeName) + } + + var schema resource.SchemaResponse + r.Schema(context.Background(), resource.SchemaRequest{}, &schema) + for _, name := range []string{"id", "name", "project_id", "source_path", "source_sha256"} { + if _, ok := schema.Schema.Attributes[name]; !ok { + t.Fatalf("extension schema missing %s attribute", name) + } + } +} + +func TestExtensionResourceConfigure(t *testing.T) { + t.Parallel() + + t.Run("durable client", func(t *testing.T) { + t.Parallel() + r := &extensionResource{} + var resp resource.ConfigureResponse + r.Configure(context.Background(), resource.ConfigureRequest{ProviderData: kernelclient.Clients{}}, &resp) + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if r.client == nil { + t.Fatal("extension client was not configured") + } + }) + + t.Run("unexpected provider data", func(t *testing.T) { + t.Parallel() + r := &extensionResource{} + var resp resource.ConfigureResponse + r.Configure(context.Background(), resource.ConfigureRequest{ProviderData: "not a client"}, &resp) + if len(resp.Diagnostics) != 1 || resp.Diagnostics[0].Summary() != "Unexpected Kernel Client Type" { + t.Fatalf("diagnostics = %v, want Unexpected Kernel Client Type", resp.Diagnostics) + } + }) + + t.Run("nil provider data", func(t *testing.T) { + t.Parallel() + r := &extensionResource{} + var resp resource.ConfigureResponse + r.Configure(context.Background(), resource.ConfigureRequest{}, &resp) + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if r.client != nil { + t.Fatal("nil provider data configured a client") + } + }) +} + +func TestExtensionResourceRejectsUpdate(t *testing.T) { + t.Parallel() + + r := &extensionResource{} + var resp resource.UpdateResponse + r.Update(context.Background(), resource.UpdateRequest{}, &resp) + + if len(resp.Diagnostics) != 1 || resp.Diagnostics[0].Summary() != "Unexpected Kernel Extension Update" { + t.Fatalf("diagnostics = %v, want Unexpected Kernel Extension Update", resp.Diagnostics) + } +} diff --git a/internal/resources/extension/modify_plan.go b/internal/resources/extension/modify_plan.go new file mode 100644 index 0000000..b0290f1 --- /dev/null +++ b/internal/resources/extension/modify_plan.go @@ -0,0 +1,61 @@ +package extension + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func modifyExtensionPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + if req.Plan.Raw.IsNull() { + return + } + + var config extensionModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + var plan extensionModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + requiresArchive := req.State.Raw.IsNull() + if !requiresArchive { + var state extensionModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + requiresArchive = extensionReplacementPlanned(state, plan) + } + if !requiresArchive { + return + } + + if config.SourcePath.IsNull() { + resp.Diagnostics.AddAttributeError( + path.Root("source_path"), + "Missing Extension Source Path", + "source_path must be configured when creating or replacing a Kernel extension.", + ) + } + if config.SourceSHA256.IsNull() { + resp.Diagnostics.AddAttributeError( + path.Root("source_sha256"), + "Missing Extension Source Checksum", + "source_sha256 must be configured when creating or replacing a Kernel extension. Use filesha256(source_path) to track the exact archive bytes.", + ) + } +} + +func extensionReplacementPlanned(state, plan extensionModel) bool { + return extensionStringChanged(state.Name, plan.Name) || + extensionStringChanged(state.ProjectID, plan.ProjectID) || + extensionStringChanged(state.SourceSHA256, plan.SourceSHA256) +} + +func extensionStringChanged(state, plan types.String) bool { + return !state.Equal(plan) +} diff --git a/internal/resources/extension/modify_plan_test.go b/internal/resources/extension/modify_plan_test.go new file mode 100644 index 0000000..c8f0994 --- /dev/null +++ b/internal/resources/extension/modify_plan_test.go @@ -0,0 +1,217 @@ +package extension + +import ( + "context" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestModifyExtensionPlanRequiresArchiveForCreate(t *testing.T) { + t.Parallel() + + config := extensionPlanModel() + config.SourcePath = types.StringNull() + config.SourceSHA256 = types.StringNull() + req := extensionModifyPlanRequest(t, config, extensionModel{}, config, false, true) + var resp resource.ModifyPlanResponse + + modifyExtensionPlan(context.Background(), req, &resp) + + assertExtensionPlanDiagnosticPath(t, resp.Diagnostics, path.Root("source_path")) + assertExtensionPlanDiagnosticPath(t, resp.Diagnostics, path.Root("source_sha256")) +} + +func TestModifyExtensionPlanRequiresArchiveForImmutableReplacement(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + change func(*extensionModel) + omitChecksum bool + }{ + "name": { + change: func(plan *extensionModel) { plan.Name = types.StringValue("New") }, + omitChecksum: true, + }, + "project_id": { + change: func(plan *extensionModel) { plan.ProjectID = types.StringValue("project_new") }, + omitChecksum: true, + }, + "source_sha256": { + change: func(plan *extensionModel) { plan.SourceSHA256 = types.StringValue(extensionChecksum("b")) }, + }, + } + + for name, change := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + state := extensionPlanModel() + plan := state + change.change(&plan) + config := plan + config.SourcePath = types.StringNull() + if change.omitChecksum { + config.SourceSHA256 = types.StringNull() + } + req := extensionModifyPlanRequest(t, config, state, plan, true, true) + var resp resource.ModifyPlanResponse + + modifyExtensionPlan(context.Background(), req, &resp) + + assertExtensionPlanDiagnosticPath(t, resp.Diagnostics, path.Root("source_path")) + if change.omitChecksum { + assertExtensionPlanDiagnosticPath(t, resp.Diagnostics, path.Root("source_sha256")) + } + }) + } +} + +func TestModifyExtensionPlanAllowsCompleteCreateOrReplacement(t *testing.T) { + t.Parallel() + + state := extensionPlanModel() + plan := state + plan.Name = types.StringValue("New") + config := plan + config.SourcePath = types.StringValue("extension.zip") + req := extensionModifyPlanRequest(t, config, state, plan, true, true) + var resp resource.ModifyPlanResponse + + modifyExtensionPlan(context.Background(), req, &resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } +} + +func TestModifyExtensionPlanDoesNotRequireArchiveForStableStateOrDestroy(t *testing.T) { + t.Parallel() + + state := extensionPlanModel() + config := state + config.SourcePath = types.StringNull() + config.SourceSHA256 = types.StringNull() + + t.Run("stable state", func(t *testing.T) { + t.Parallel() + req := extensionModifyPlanRequest(t, config, state, state, true, true) + var resp resource.ModifyPlanResponse + modifyExtensionPlan(context.Background(), req, &resp) + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + }) + + t.Run("destroy", func(t *testing.T) { + t.Parallel() + req := extensionModifyPlanRequest(t, config, state, extensionModel{}, true, false) + var resp resource.ModifyPlanResponse + modifyExtensionPlan(context.Background(), req, &resp) + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + }) +} + +func TestModifyExtensionPlanDefersUnknownArchiveInputs(t *testing.T) { + t.Parallel() + + config := extensionPlanModel() + config.SourcePath = types.StringUnknown() + config.SourceSHA256 = types.StringUnknown() + req := extensionModifyPlanRequest(t, config, extensionModel{}, config, false, true) + var resp resource.ModifyPlanResponse + + modifyExtensionPlan(context.Background(), req, &resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics for deferred inputs: %v", resp.Diagnostics) + } +} + +func TestModifyExtensionPlanRequiresArchiveForUnknownReplacementValue(t *testing.T) { + t.Parallel() + + state := extensionPlanModel() + plan := state + plan.Name = types.StringUnknown() + config := plan + config.SourcePath = types.StringNull() + config.SourceSHA256 = types.StringNull() + req := extensionModifyPlanRequest(t, config, state, plan, true, true) + var resp resource.ModifyPlanResponse + + modifyExtensionPlan(context.Background(), req, &resp) + + assertExtensionPlanDiagnosticPath(t, resp.Diagnostics, path.Root("source_path")) + assertExtensionPlanDiagnosticPath(t, resp.Diagnostics, path.Root("source_sha256")) +} + +func extensionModifyPlanRequest(t *testing.T, config, state, plan extensionModel, hasState, hasPlan bool) resource.ModifyPlanRequest { + t.Helper() + ctx := context.Background() + schema := extensionSchema() + + configValue := encodeExtensionPlanModel(t, config) + req := resource.ModifyPlanRequest{ + Config: tfsdk.Config{Schema: schema, Raw: configValue}, + State: tfsdk.State{Schema: schema}, + Plan: tfsdk.Plan{Schema: schema}, + } + if hasState { + if diags := req.State.Set(ctx, state); diags.HasError() { + t.Fatalf("set extension state: %v", diags) + } + } else { + req.State.RemoveResource(ctx) + } + if hasPlan { + if diags := req.Plan.Set(ctx, plan); diags.HasError() { + t.Fatalf("set extension plan: %v", diags) + } + } else { + req.Plan.Raw = tftypes.NewValue(schema.Type().TerraformType(ctx), nil) + } + return req +} + +func encodeExtensionPlanModel(t *testing.T, model extensionModel) tftypes.Value { + t.Helper() + var encoded tfsdk.Plan + encoded.Schema = extensionSchema() + if diags := encoded.Set(context.Background(), model); diags.HasError() { + t.Fatalf("encode extension model: %v", diags) + } + return encoded.Raw +} + +func extensionPlanModel() extensionModel { + return extensionModel{ + ID: types.StringValue("extension_123"), + Name: types.StringValue("Extension"), + ProjectID: types.StringValue("project_123"), + SourcePath: types.StringNull(), + SourceSHA256: types.StringValue(extensionChecksum("a")), + } +} + +func extensionChecksum(character string) string { + return strings.Repeat(character, 64) +} + +func assertExtensionPlanDiagnosticPath(t *testing.T, diags diag.Diagnostics, want path.Path) { + t.Helper() + for _, diagnostic := range diags { + withPath, ok := diagnostic.(diag.DiagnosticWithPath) + if ok && withPath.Path().Equal(want) { + return + } + } + t.Fatalf("diagnostics = %v, want path %s", diags, want) +} diff --git a/internal/resources/extension/resource.go b/internal/resources/extension/resource.go new file mode 100644 index 0000000..1de7c7e --- /dev/null +++ b/internal/resources/extension/resource.go @@ -0,0 +1,85 @@ +package extension + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +var ( + _ resource.Resource = (*extensionResource)(nil) + _ resource.ResourceWithConfigure = (*extensionResource)(nil) + _ resource.ResourceWithImportState = (*extensionResource)(nil) + _ resource.ResourceWithModifyPlan = (*extensionResource)(nil) +) + +type extensionClient interface { + extensionUploader + extensionReader + extensionDeleter + extensionImporter +} + +type extensionResource struct { + client extensionClient +} + +func NewResource() resource.Resource { + return &extensionResource{} +} + +func (r *extensionResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_extension" +} + +func (r *extensionResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = extensionSchema() +} + +func (r *extensionResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(extensionClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Kernel Client Type", + "Expected provider data to implement the extension durable client contract.", + ) + return + } + + r.client = client +} + +func (r *extensionResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + defaultProjectID := "" + if r.client != nil { + defaultProjectID = r.client.DefaultProjectID() + } + createExtensionResource(ctx, r.client, defaultProjectID, req, resp) +} + +func (r *extensionResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + readExtensionResource(ctx, r.client, req, resp) +} + +func (r *extensionResource) Update(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.AddError( + "Unexpected Kernel Extension Update", + "Kernel extensions are immutable. Terraform should replace the extension when durable configuration changes; reaching Update indicates a provider planning error.", + ) +} + +func (r *extensionResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + deleteExtensionResource(ctx, r.client, req, resp) +} + +func (r *extensionResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + importExtensionResource(ctx, r.client, req, resp) +} + +func (r *extensionResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + modifyExtensionPlan(ctx, req, resp) +}