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) +}