From 93e6c2be576dfed67ef1b6223705adc2bafeb986 Mon Sep 17 00:00:00 2001 From: Ilyaas Yusuf Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:17:30 -0400 Subject: [PATCH 1/2] Tighten browser pool update boundaries (#115) * Tighten browser pool update boundaries * Prevent no-op browser pool updates * Normalize imported empty extension lists * Fix browser pool planning after restack Adapt tests to the updated base, preserve unknown nested refresh defaults, follow organization-specific fill-rate limits, and clear profiles in place through the SDK-documented payload. * Warn before browser pool replacement * Close browser pool planning test gaps --- docs/resources/browser_pool.md | 10 +- .../chrome_policy_plan_modifier.go | 38 ++++ internal/resources/browserpool/expand.go | 18 +- internal/resources/browserpool/expand_test.go | 26 ++- .../extension_ids_plan_modifier.go | 30 +++ internal/resources/browserpool/resource.go | 20 +- .../resources/browserpool/resource_test.go | 61 +++-- internal/resources/browserpool/schema.go | 56 ++++- internal/resources/browserpool/schema_test.go | 209 ++++++++++++++++++ 9 files changed, 427 insertions(+), 41 deletions(-) create mode 100644 internal/resources/browserpool/chrome_policy_plan_modifier.go create mode 100644 internal/resources/browserpool/extension_ids_plan_modifier.go diff --git a/docs/resources/browser_pool.md b/docs/resources/browser_pool.md index bc79783..89f0d87 100644 --- a/docs/resources/browser_pool.md +++ b/docs/resources/browser_pool.md @@ -22,19 +22,19 @@ Kernel browser pool durable configuration. ### Optional - `chrome_policy` (String) JSON object of Chrome enterprise policy overrides. Stored as written; key order and whitespace are ignored when detecting changes. -- `extension_ids` (List of String) Ordered extension IDs to load into browsers created by this pool. -- `fill_rate_per_minute` (Number) Percentage of the pool to fill per minute. +- `extension_ids` (List of String) Ordered extension IDs to load into browsers created by this pool. For an existing pool, omission preserves the current extensions; set an empty list to clear them. +- `fill_rate_per_minute` (Number) Percentage of the pool to fill per minute. The maximum is determined by the Kernel organization. - `headless` (Boolean) Launch browsers using a headless image. - `kiosk_mode` (Boolean) Launch browsers in kiosk mode. -- `name` (String) Optional browser pool name. Must be unique within the project. -- `profile_id` (String) Optional profile ID to load for browsers created by this pool. +- `name` (String) Optional browser pool name. Must be unique within the project. Removing an existing name replaces the pool because the API cannot clear it in place. +- `profile_id` (String) Optional profile ID to load for browsers created by this pool. Removing an existing profile ID clears the profile in place. - `project_id` (String) Project this browser pool belongs to. Defaults to the provider `project_id` when unset; when neither is set, the API key's project binding determines the project. Once created the pool keeps its project, and changing this attribute replaces the pool. - `proxy_id` (String) Optional proxy ID to use for browsers created by this pool. - `rebuild_idle_browsers_on_update` (Boolean) When true, changes to profile_id, proxy_id, extension_ids, chrome_policy, viewport, headless, kiosk_mode, stealth, or start_url discard browsers that are idle when the update runs so replacements use the new configuration. Browsers that are warming or currently leased are not rebuilt. Kernel does not store this provider-local setting, so imported browser pools default to false unless configured otherwise. - `start_url` (String) Optional URL to navigate to when a browser is warmed into the pool. - `stealth` (Boolean) Launch browsers in stealth mode. - `timeout_seconds` (Number) Default idle timeout in seconds for acquired browsers. -- `viewport` (Attributes) Optional browser viewport. (see [below for nested schema](#nestedatt--viewport)) +- `viewport` (Attributes) Optional browser viewport. Removing an existing viewport replaces the pool because the API cannot clear it in place. (see [below for nested schema](#nestedatt--viewport)) ### Read-Only diff --git a/internal/resources/browserpool/chrome_policy_plan_modifier.go b/internal/resources/browserpool/chrome_policy_plan_modifier.go new file mode 100644 index 0000000..3322d17 --- /dev/null +++ b/internal/resources/browserpool/chrome_policy_plan_modifier.go @@ -0,0 +1,38 @@ +package browserpool + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +var _ planmodifier.String = preserveEquivalentChromePolicy{} + +// preserveEquivalentChromePolicy keeps the prior state spelling when the +// configured JSON object is semantically unchanged. Terraform's protocol does +// not use a custom string value's semantic equality to suppress a resource +// update during planning, so this must happen explicitly at the schema edge. +type preserveEquivalentChromePolicy struct{} + +func (preserveEquivalentChromePolicy) Description(context.Context) string { + return "Preserves the prior chrome_policy value when the configured JSON object is semantically equivalent." +} + +func (m preserveEquivalentChromePolicy) MarkdownDescription(ctx context.Context) string { + return m.Description(ctx) +} + +func (preserveEquivalentChromePolicy) PlanModifyString(_ context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + if req.StateValue.IsNull() || req.StateValue.IsUnknown() || req.PlanValue.IsNull() || req.PlanValue.IsUnknown() { + return + } + + state, stateDiags := normalizeChromePolicyJSON(req.StateValue.ValueString()) + planned, plannedDiags := normalizeChromePolicyJSON(req.PlanValue.ValueString()) + if stateDiags.HasError() || plannedDiags.HasError() { + return + } + if state == planned { + resp.PlanValue = req.StateValue + } +} diff --git a/internal/resources/browserpool/expand.go b/internal/resources/browserpool/expand.go index 7db8cd8..b58c436 100644 --- a/internal/resources/browserpool/expand.go +++ b/internal/resources/browserpool/expand.go @@ -125,9 +125,14 @@ func expandUpdateParams(ctx context.Context, plan, state browserPoolModel) (kern params.Size = kernel.Int(plan.Size.ValueInt64()) hasPatch = true } - if !plan.ProfileID.Equal(state.ProfileID) && isKnownString(plan.ProfileID) { - params.Profile.ID = kernel.String(plan.ProfileID.ValueString()) - hasPatch = true + if !plan.ProfileID.Equal(state.ProfileID) { + if plan.ProfileID.IsNull() { + params.Profile.ID = kernel.String("") + hasPatch = true + } else if isKnownString(plan.ProfileID) { + params.Profile.ID = kernel.String(plan.ProfileID.ValueString()) + hasPatch = true + } } if !plan.ProxyID.Equal(state.ProxyID) { if plan.ProxyID.IsNull() { @@ -339,13 +344,6 @@ func validateSupportedUpdateClears(diags *diag.Diagnostics, plan, state browserP "The Kernel browser pool API does not currently support clearing a browser pool name. Set a new name or keep the existing name.", ) } - if clearsString(plan.ProfileID, state.ProfileID) { - addUnsupportedClearDiagnostic( - diags, - path.Root("profile_id"), - "The Kernel browser pool API does not currently expose a safe profile clear payload. Set a new profile_id or keep the existing profile_id.", - ) - } if plan.Viewport.IsNull() && !state.Viewport.IsNull() && !state.Viewport.IsUnknown() { addUnsupportedClearDiagnostic( diags, diff --git a/internal/resources/browserpool/expand_test.go b/internal/resources/browserpool/expand_test.go index 343f103..c56fcf6 100644 --- a/internal/resources/browserpool/expand_test.go +++ b/internal/resources/browserpool/expand_test.go @@ -594,7 +594,7 @@ func TestExpandUpdateParamsClearsSupportedDurableConfig(t *testing.T) { plan := browserPoolModel{ Name: types.StringValue("pool-a"), Size: types.Int64Value(1), - ProfileID: types.StringValue("profile-1"), + ProfileID: types.StringNull(), ProxyID: types.StringNull(), ExtensionIDs: types.ListNull(types.StringType), ChromePolicy: chromePolicyNull(), @@ -632,6 +632,7 @@ func TestExpandUpdateParamsClearsSupportedDurableConfig(t *testing.T) { body := marshalSDKParams(t, params) want := map[string]any{ "discard_all_idle": true, + "profile": map[string]any{"id": ""}, "proxy_id": "", "extensions": []any{}, "chrome_policy": map[string]any{}, @@ -642,6 +643,27 @@ func TestExpandUpdateParamsClearsSupportedDurableConfig(t *testing.T) { } } +func TestExpandUpdateParamsClearsOnlyProfile(t *testing.T) { + state := updateModelForTest() + state.ProfileID = types.StringValue("profile-1") + plan := state + plan.ProfileID = types.StringNull() + + params, hasPatch, diags := expandUpdateParams(context.Background(), plan, state) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if !hasPatch { + t.Fatal("clearing profile_id must produce an API patch") + } + + body := marshalSDKParams(t, params) + want := map[string]any{"profile": map[string]any{"id": ""}} + if !jsonEqual(t, body, want) { + t.Fatalf("expanded SDK JSON mismatch\ngot: %#v\nwant: %#v", body, want) + } +} + func TestExpandUpdateParamsRejectsUnsupportedClears(t *testing.T) { plan := browserPoolModel{ Name: types.StringNull(), @@ -678,7 +700,7 @@ func TestExpandUpdateParamsRejectsUnsupportedClears(t *testing.T) { if !diags.HasError() { t.Fatal("expected diagnostics for unsupported clear operations") } - for _, want := range []path.Path{path.Root("name"), path.Root("profile_id"), path.Root("viewport")} { + for _, want := range []path.Path{path.Root("name"), path.Root("viewport")} { if !hasDiagnosticPath(diags, want) { t.Fatalf("expected diagnostic at %s, got %v", want.String(), diags) } diff --git a/internal/resources/browserpool/extension_ids_plan_modifier.go b/internal/resources/browserpool/extension_ids_plan_modifier.go new file mode 100644 index 0000000..aee0bd8 --- /dev/null +++ b/internal/resources/browserpool/extension_ids_plan_modifier.go @@ -0,0 +1,30 @@ +package browserpool + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ planmodifier.List = defaultEmptyExtensionIDsOnCreate{} + +// defaultEmptyExtensionIDsOnCreate resolves an omitted extension list only for +// a new pool. Existing pools use UseStateForUnknown so imports and refreshes +// preserve the extension IDs returned by the API. +type defaultEmptyExtensionIDsOnCreate struct{} + +func (defaultEmptyExtensionIDsOnCreate) Description(context.Context) string { + return "Defaults omitted extension_ids to an empty list when creating a browser pool." +} + +func (m defaultEmptyExtensionIDsOnCreate) MarkdownDescription(ctx context.Context) string { + return m.Description(ctx) +} + +func (defaultEmptyExtensionIDsOnCreate) PlanModifyList(_ context.Context, req planmodifier.ListRequest, resp *planmodifier.ListResponse) { + if !req.State.Raw.IsNull() || !req.ConfigValue.IsNull() || !req.PlanValue.IsUnknown() { + return + } + resp.PlanValue = types.ListValueMust(types.StringType, nil) +} diff --git a/internal/resources/browserpool/resource.go b/internal/resources/browserpool/resource.go index 29ebea1..0380b65 100644 --- a/internal/resources/browserpool/resource.go +++ b/internal/resources/browserpool/resource.go @@ -10,6 +10,7 @@ import ( "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/types" kernel "github.com/kernel/kernel-go-sdk" "github.com/kernel/terraform-provider-kernel/internal/projectscope" ) @@ -60,7 +61,16 @@ func (r *browserPoolResource) ModifyPlan(ctx context.Context, req resource.Modif resp.Diagnostics.Append(req.State.Get(ctx, &state)...) var config browserPoolModel resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) - if resp.Diagnostics.HasError() || !idleBrowserRebuildWarningRequired(plan, state, config) { + if resp.Diagnostics.HasError() { + return + } + if browserPoolReplacementRequired(plan, state) { + resp.Diagnostics.AddWarning( + "Browser Pool Will Be Replaced", + "Applying this plan will replace the browser pool. Completing the replacement deletes the existing pool and all browsers in it. Kernel blocks this provider's non-forceful deletion while any browser is leased; release leased browsers before applying.", + ) + } + if !idleBrowserRebuildWarningRequired(plan, state, config) { return } @@ -71,6 +81,13 @@ func (r *browserPoolResource) ModifyPlan(ctx context.Context, req resource.Modif ) } +func browserPoolReplacementRequired(plan, state browserPoolModel) bool { + // Keep these conditions aligned with the schema's replacement plan modifiers. + projectChanges := !plan.ProjectID.IsUnknown() && !plan.ProjectID.Equal(state.ProjectID) + clearsViewport := plan.Viewport.IsNull() && !state.Viewport.IsNull() && !state.Viewport.IsUnknown() + return projectChanges || clearsString(plan.Name, state.Name) || clearsViewport +} + func idleBrowserRebuildWarningRequired(plan, state, config browserPoolModel) bool { rebuildPossible := isKnownBool(plan.RebuildIdle) && plan.RebuildIdle.ValueBool() rebuildPossible = rebuildPossible || config.RebuildIdle.IsUnknown() || @@ -199,6 +216,7 @@ func (r *browserPoolResource) ImportState(ctx context.Context, req resource.Impo resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), poolID)...) resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectscope.StateValue(projectID))...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("extension_ids"), types.ListValueMust(types.StringType, nil))...) } func parseImportID(id string) (projectID, poolID string, ok bool) { diff --git a/internal/resources/browserpool/resource_test.go b/internal/resources/browserpool/resource_test.go index bc0149a..a7d51b0 100644 --- a/internal/resources/browserpool/resource_test.go +++ b/internal/resources/browserpool/resource_test.go @@ -97,16 +97,17 @@ func TestResourceMetadataAndSchema(t *testing.T) { } } -func TestModifyPlanWarnsBeforeIdleBrowserRebuild(t *testing.T) { +func TestModifyPlanWarnings(t *testing.T) { t.Parallel() tests := []struct { - name string - apply func(*browserPoolModel) - applyConfig func(*browserPoolModel) - nullPlan bool - nullState bool - wantWarn bool + name string + apply func(*browserPoolModel) + applyConfig func(*browserPoolModel) + nullPlan bool + nullState bool + wantIdleWarn bool + wantReplacementWarn bool }{ { name: "known launch change while enabled", @@ -114,7 +115,7 @@ func TestModifyPlanWarnsBeforeIdleBrowserRebuild(t *testing.T) { plan.Stealth = types.BoolValue(true) plan.RebuildIdle = types.BoolValue(true) }, - wantWarn: true, + wantIdleWarn: true, }, { name: "launch change while disabled", @@ -158,7 +159,7 @@ func TestModifyPlanWarnsBeforeIdleBrowserRebuild(t *testing.T) { plan.ProfileID = types.StringUnknown() plan.RebuildIdle = types.BoolValue(true) }, - wantWarn: true, + wantIdleWarn: true, }, { name: "configured unknown computed launch value", @@ -166,7 +167,7 @@ func TestModifyPlanWarnsBeforeIdleBrowserRebuild(t *testing.T) { plan.Stealth = types.BoolUnknown() plan.RebuildIdle = types.BoolValue(true) }, - wantWarn: true, + wantIdleWarn: true, }, { name: "unrelated unknown with known launch change", @@ -175,7 +176,7 @@ func TestModifyPlanWarnsBeforeIdleBrowserRebuild(t *testing.T) { plan.Stealth = types.BoolValue(true) plan.RebuildIdle = types.BoolValue(true) }, - wantWarn: true, + wantIdleWarn: true, }, { name: "replacement with known launch change", @@ -184,14 +185,23 @@ func TestModifyPlanWarnsBeforeIdleBrowserRebuild(t *testing.T) { plan.Stealth = types.BoolValue(true) plan.RebuildIdle = types.BoolValue(true) }, + wantReplacementWarn: true, }, { - name: "unsupported clear blocks launch update", + name: "unsupported name clear blocks launch update", apply: func(plan *browserPoolModel) { plan.Name = types.StringNull() plan.Stealth = types.BoolValue(true) plan.RebuildIdle = types.BoolValue(true) }, + wantReplacementWarn: true, + }, + { + name: "unsupported viewport clear", + apply: func(plan *browserPoolModel) { + plan.Viewport = types.ObjectNull(plan.Viewport.AttributeTypes(context.Background())) + }, + wantReplacementWarn: true, }, { name: "unknown required viewport dimension", @@ -199,7 +209,7 @@ func TestModifyPlanWarnsBeforeIdleBrowserRebuild(t *testing.T) { plan.Viewport = viewportObjectForTest(types.Int64Unknown(), types.Int64Value(800), types.Int64Value(60)) plan.RebuildIdle = types.BoolValue(true) }, - wantWarn: true, + wantIdleWarn: true, }, { name: "unknown rebuild preference with known launch change", @@ -207,7 +217,7 @@ func TestModifyPlanWarnsBeforeIdleBrowserRebuild(t *testing.T) { plan.Stealth = types.BoolValue(true) plan.RebuildIdle = types.BoolUnknown() }, - wantWarn: true, + wantIdleWarn: true, }, { name: "create", @@ -245,11 +255,14 @@ func TestModifyPlanWarnsBeforeIdleBrowserRebuild(t *testing.T) { if resp.Diagnostics.HasError() { t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) } - gotWarn := resp.Diagnostics.WarningsCount() == 1 - if gotWarn != test.wantWarn { - t.Fatalf("warning present = %t, want %t: %v", gotWarn, test.wantWarn, resp.Diagnostics) + wantWarnings := 0 + if test.wantIdleWarn || test.wantReplacementWarn { + wantWarnings = 1 } - if test.wantWarn { + if resp.Diagnostics.WarningsCount() != wantWarnings { + t.Fatalf("warnings = %d, want %d: %v", resp.Diagnostics.WarningsCount(), wantWarnings, resp.Diagnostics) + } + if test.wantIdleWarn { if !hasDiagnosticPath(resp.Diagnostics, path.Root("rebuild_idle_browsers_on_update")) { t.Fatalf("expected warning at rebuild_idle_browsers_on_update, got %v", resp.Diagnostics) } @@ -260,6 +273,15 @@ func TestModifyPlanWarnsBeforeIdleBrowserRebuild(t *testing.T) { t.Fatalf("warning does not disclose the possible discard: %v", resp.Diagnostics.Warnings()[0]) } } + if test.wantReplacementWarn { + warning := resp.Diagnostics.Warnings()[0] + if !strings.Contains(warning.Summary(), "Browser Pool Will Be Replaced") { + t.Fatalf("unexpected warning: %v", warning) + } + if !strings.Contains(warning.Detail(), "all browsers") || !strings.Contains(warning.Detail(), "leased") { + t.Fatalf("replacement warning omits browser deletion or lease blocking: %v", warning) + } + } if !resp.Plan.Raw.Equal(req.Plan.Raw) { t.Fatal("ModifyPlan changed the planned state") } @@ -362,6 +384,8 @@ func TestResourceImportState(t *testing.T) { resp.Diagnostics.Append(resp.State.GetAttribute(ctx, path.Root("id"), &id)...) var projectID types.String resp.Diagnostics.Append(resp.State.GetAttribute(ctx, path.Root("project_id"), &projectID)...) + var extensionIDs types.List + resp.Diagnostics.Append(resp.State.GetAttribute(ctx, path.Root("extension_ids"), &extensionIDs)...) if resp.Diagnostics.HasError() { t.Fatalf("read imported state: %v", resp.Diagnostics) } @@ -371,6 +395,7 @@ func TestResourceImportState(t *testing.T) { if !projectID.Equal(test.wantProjectID) { t.Fatalf("imported project_id = %v, want %v", projectID, test.wantProjectID) } + assertStringList(t, extensionIDs, []string{}) }) } } diff --git a/internal/resources/browserpool/schema.go b/internal/resources/browserpool/schema.go index 6693221..361d39c 100644 --- a/internal/resources/browserpool/schema.go +++ b/internal/resources/browserpool/schema.go @@ -1,11 +1,17 @@ package browserpool import ( + "context" + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" rschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -25,7 +31,10 @@ func BrowserPoolSchema() rschema.Schema { }, "name": rschema.StringAttribute{ Optional: true, - MarkdownDescription: "Optional browser pool name. Must be unique within the project.", + MarkdownDescription: "Optional browser pool name. Must be unique within the project. Removing an existing name replaces the pool because the API cannot clear it in place.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplaceIf(requiresReplaceOnStringClear, "Removing the configured value replaces the browser pool.", "Removing the configured value replaces the browser pool."), + }, Validators: []validator.String{ browserPoolNameValidator{}, }, @@ -55,7 +64,7 @@ func BrowserPoolSchema() rschema.Schema { }, "profile_id": rschema.StringAttribute{ Optional: true, - MarkdownDescription: "Optional profile ID to load for browsers created by this pool.", + MarkdownDescription: "Optional profile ID to load for browsers created by this pool. Removing an existing profile ID clears the profile in place.", Validators: []validator.String{ stringvalidator.LengthAtLeast(1), }, @@ -69,8 +78,13 @@ func BrowserPoolSchema() rschema.Schema { }, "extension_ids": rschema.ListAttribute{ Optional: true, + Computed: true, ElementType: types.StringType, - MarkdownDescription: "Ordered extension IDs to load into browsers created by this pool.", + MarkdownDescription: "Ordered extension IDs to load into browsers created by this pool. For an existing pool, omission preserves the current extensions; set an empty list to clear them.", + PlanModifiers: []planmodifier.List{ + defaultEmptyExtensionIDsOnCreate{}, + listplanmodifier.UseStateForUnknown(), + }, Validators: []validator.List{ listvalidator.SizeAtMost(maxBrowserPoolExtensions), listvalidator.NoNullValues(), @@ -81,13 +95,19 @@ func BrowserPoolSchema() rschema.Schema { Optional: true, CustomType: chromePolicyType{}, MarkdownDescription: "JSON object of Chrome enterprise policy overrides. Stored as written; key order and whitespace are ignored when detecting changes.", + PlanModifiers: []planmodifier.String{ + preserveEquivalentChromePolicy{}, + }, Validators: []validator.String{ chromePolicyJSONValidator{}, }, }, "viewport": rschema.SingleNestedAttribute{ Optional: true, - MarkdownDescription: "Optional browser viewport.", + MarkdownDescription: "Optional browser viewport. Removing an existing viewport replaces the pool because the API cannot clear it in place.", + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.RequiresReplaceIf(requiresReplaceOnObjectClear, "Removing the configured value replaces the browser pool.", "Removing the configured value replaces the browser pool."), + }, Attributes: map[string]rschema.Attribute{ "width": rschema.Int64Attribute{ Required: true, @@ -107,6 +127,9 @@ func BrowserPoolSchema() rschema.Schema { Optional: true, Computed: true, MarkdownDescription: "Optional display refresh rate in Hz.", + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseNonNullStateForUnknown(), + }, Validators: []validator.Int64{ int64validator.AtLeast(minViewportRefreshRate), }, @@ -117,16 +140,25 @@ func BrowserPoolSchema() rschema.Schema { Optional: true, Computed: true, MarkdownDescription: "Launch browsers using a headless image.", + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, }, "kiosk_mode": rschema.BoolAttribute{ Optional: true, Computed: true, MarkdownDescription: "Launch browsers in kiosk mode.", + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, }, "stealth": rschema.BoolAttribute{ Optional: true, Computed: true, MarkdownDescription: "Launch browsers in stealth mode.", + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, }, "start_url": rschema.StringAttribute{ Optional: true, @@ -140,6 +172,9 @@ func BrowserPoolSchema() rschema.Schema { Optional: true, Computed: true, MarkdownDescription: "Default idle timeout in seconds for acquired browsers.", + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, Validators: []validator.Int64{ int64validator.Between(minTimeoutSeconds, maxTimeoutSeconds), }, @@ -147,7 +182,10 @@ func BrowserPoolSchema() rschema.Schema { "fill_rate_per_minute": rschema.Int64Attribute{ Optional: true, Computed: true, - MarkdownDescription: "Percentage of the pool to fill per minute.", + MarkdownDescription: "Percentage of the pool to fill per minute. The maximum is determined by the Kernel organization.", + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, Validators: []validator.Int64{ int64validator.AtLeast(minFillRatePerMinute), }, @@ -161,3 +199,11 @@ func BrowserPoolSchema() rschema.Schema { }, } } + +func requiresReplaceOnStringClear(_ context.Context, req planmodifier.StringRequest, resp *stringplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = req.PlanValue.IsNull() && !req.StateValue.IsNull() && !req.StateValue.IsUnknown() +} + +func requiresReplaceOnObjectClear(_ context.Context, req planmodifier.ObjectRequest, resp *objectplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = req.PlanValue.IsNull() && !req.StateValue.IsNull() && !req.StateValue.IsUnknown() +} diff --git a/internal/resources/browserpool/schema_test.go b/internal/resources/browserpool/schema_test.go index 7f41f18..48d9b98 100644 --- a/internal/resources/browserpool/schema_test.go +++ b/internal/resources/browserpool/schema_test.go @@ -185,6 +185,44 @@ func TestSchemaProjectIDSemantics(t *testing.T) { } } +func TestSchemaUnsupportedClearsPlanReplacement(t *testing.T) { + s := BrowserPoolSchema() + + name := stringAttribute(t, s, "name") + _, requiresReplace := runStringPlanModifiers(t, name, + types.StringValue("configured"), types.StringNull(), types.StringNull()) + if !requiresReplace { + t.Fatal("clearing name must replace the pool") + } + + _, requiresReplace = runStringPlanModifiers(t, name, + types.StringValue("old"), types.StringValue("new"), types.StringValue("new")) + if requiresReplace { + t.Fatal("changing name to another value must remain an in-place update") + } + + profile := stringAttribute(t, s, "profile_id") + _, requiresReplace = runStringPlanModifiers(t, profile, + types.StringValue("profile-1"), types.StringNull(), types.StringNull()) + if requiresReplace { + t.Fatal("clearing profile_id must remain an in-place update") + } + + viewport := singleNestedAttribute(t, s, "viewport") + viewportValue := types.ObjectValueMust( + map[string]tfattr.Type{ + "width": types.Int64Type, "height": types.Int64Type, "refresh_rate": types.Int64Type, + }, + map[string]tfattr.Value{ + "width": types.Int64Value(1280), "height": types.Int64Value(800), "refresh_rate": types.Int64Value(60), + }, + ) + viewportNull := types.ObjectNull(viewportValue.AttributeTypes(context.Background())) + if !runObjectPlanModifiers(t, viewport, viewportValue, viewportNull, viewportNull) { + t.Fatal("clearing viewport must replace the pool") + } +} + func runStringPlanModifiers(t *testing.T, attr rschema.StringAttribute, state, plan, config types.String) (types.String, bool) { t.Helper() @@ -210,6 +248,31 @@ func runStringPlanModifiers(t *testing.T, attr rschema.StringAttribute, state, p return req.PlanValue, requiresReplace } +func runObjectPlanModifiers(t *testing.T, attr rschema.SingleNestedAttribute, state, plan, config types.Object) bool { + t.Helper() + + nonNullRaw := tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{}}, map[string]tftypes.Value{}) + req := planmodifier.ObjectRequest{ + State: tfsdk.State{Raw: nonNullRaw}, + Plan: tfsdk.Plan{Raw: nonNullRaw}, + StateValue: state, + PlanValue: plan, + ConfigValue: config, + } + + requiresReplace := false + for _, m := range attr.PlanModifiers { + resp := &planmodifier.ObjectResponse{PlanValue: req.PlanValue} + m.PlanModifyObject(context.Background(), req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("plan modifier returned diagnostics: %v", resp.Diagnostics) + } + req.PlanValue = resp.PlanValue + requiresReplace = requiresReplace || resp.RequiresReplace + } + return requiresReplace +} + func TestSchemaValidatesDurableNumericBounds(t *testing.T) { s := BrowserPoolSchema() @@ -245,6 +308,152 @@ func TestSchemaValidatesChromePolicyJSON(t *testing.T) { assertStringAccepts(t, attr, "chrome_policy", `{"HomepageLocation":"https://example.com"}`) } +func TestSchemaChromePolicyPreservesStateForEquivalentJSON(t *testing.T) { + attr := stringAttribute(t, BrowserPoolSchema(), "chrome_policy") + state := types.StringValue(`{"HomepageLocation":"https://example.com","RestoreOnStartup":4}`) + config := types.StringValue(`{ "RestoreOnStartup": 4, "HomepageLocation": "https://example.com" }`) + + planned, requiresReplace := runStringPlanModifiers(t, attr, state, config, config) + if requiresReplace { + t.Fatal("equivalent chrome_policy JSON must not replace the pool") + } + if !planned.Equal(state) { + t.Fatalf("equivalent chrome_policy JSON planned as %q, want prior state %q", planned.ValueString(), state.ValueString()) + } + + changed := types.StringValue(`{"HomepageLocation":"https://kernel.sh","RestoreOnStartup":4}`) + planned, _ = runStringPlanModifiers(t, attr, state, changed, changed) + if !planned.Equal(changed) { + t.Fatalf("changed chrome_policy JSON planned as %q, want configured value %q", planned.ValueString(), changed.ValueString()) + } +} + +func TestSchemaPreservesComputedDefaultsDuringUnrelatedUpdates(t *testing.T) { + s := BrowserPoolSchema() + + for _, name := range []string{"headless", "kiosk_mode", "stealth"} { + attr := boolAttribute(t, s, name) + planned := runBoolPlanModifiers(t, attr, types.BoolValue(false), types.BoolUnknown(), types.BoolNull()) + if !planned.Equal(types.BoolValue(false)) { + t.Fatalf("%s planned as %v, want prior false state", name, planned) + } + } + + for _, name := range []string{"timeout_seconds", "fill_rate_per_minute"} { + attr := int64Attribute(t, s, name) + planned := runInt64PlanModifiers(t, attr, types.Int64Value(42), types.Int64Unknown(), types.Int64Null()) + if !planned.Equal(types.Int64Value(42)) { + t.Fatalf("%s planned as %v, want prior state", name, planned) + } + } + + viewport := singleNestedAttribute(t, s, "viewport") + refreshRate := nestedInt64Attribute(t, viewport, "refresh_rate") + planned := runInt64PlanModifiers(t, refreshRate, types.Int64Value(60), types.Int64Unknown(), types.Int64Null()) + if !planned.Equal(types.Int64Value(60)) { + t.Fatalf("viewport.refresh_rate planned as %v, want prior state", planned) + } +} + +func TestSchemaLeavesNewViewportRefreshRateUnknown(t *testing.T) { + viewport := singleNestedAttribute(t, BrowserPoolSchema(), "viewport") + refreshRate := nestedInt64Attribute(t, viewport, "refresh_rate") + planned := runInt64PlanModifiers(t, refreshRate, types.Int64Null(), types.Int64Unknown(), types.Int64Null()) + if !planned.IsUnknown() { + t.Fatalf("new viewport refresh_rate planned as %v, want unknown for the API default", planned) + } +} + +func TestSchemaPreservesImportedEmptyExtensionIDsWhenConfigurationOmitsThem(t *testing.T) { + attr := listAttribute(t, BrowserPoolSchema(), "extension_ids") + if !attr.Optional || !attr.Computed || attr.Required { + t.Fatalf("extension_ids must be optional and computed, got %#v", attr) + } + empty := types.ListValueMust(types.StringType, []tfattr.Value{}) + + planned := runListPlanModifiers(t, attr, empty, types.ListUnknown(types.StringType), types.ListNull(types.StringType)) + if !planned.Equal(empty) { + t.Fatalf("empty extension_ids planned as %v, want prior empty state when omitted", planned) + } +} + +func TestSchemaDefaultsOmittedExtensionIDsOnlyDuringCreate(t *testing.T) { + attr := listAttribute(t, BrowserPoolSchema(), "extension_ids") + empty := types.ListValueMust(types.StringType, []tfattr.Value{}) + nullResource := tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{}}, nil) + + planned := runListPlanModifiersWithStateRaw(t, attr, nullResource, + types.ListNull(types.StringType), types.ListUnknown(types.StringType), types.ListNull(types.StringType)) + if !planned.Equal(empty) { + t.Fatalf("omitted extension_ids planned as %v during create, want empty list", planned) + } + + planned = runListPlanModifiersWithStateRaw(t, attr, nullResource, + types.ListNull(types.StringType), types.ListUnknown(types.StringType), types.ListUnknown(types.StringType)) + if !planned.IsUnknown() { + t.Fatalf("unknown configured extension_ids planned as %v, want unknown preserved", planned) + } +} + +func runBoolPlanModifiers(t *testing.T, attr rschema.BoolAttribute, state, plan, config types.Bool) types.Bool { + t.Helper() + nonNullRaw := tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{}}, map[string]tftypes.Value{}) + req := planmodifier.BoolRequest{ + State: tfsdk.State{Raw: nonNullRaw}, Plan: tfsdk.Plan{Raw: nonNullRaw}, + StateValue: state, PlanValue: plan, ConfigValue: config, + } + for _, m := range attr.PlanModifiers { + resp := &planmodifier.BoolResponse{PlanValue: req.PlanValue} + m.PlanModifyBool(context.Background(), req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("plan modifier returned diagnostics: %v", resp.Diagnostics) + } + req.PlanValue = resp.PlanValue + } + return req.PlanValue +} + +func runInt64PlanModifiers(t *testing.T, attr rschema.Int64Attribute, state, plan, config types.Int64) types.Int64 { + t.Helper() + nonNullRaw := tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{}}, map[string]tftypes.Value{}) + req := planmodifier.Int64Request{ + State: tfsdk.State{Raw: nonNullRaw}, Plan: tfsdk.Plan{Raw: nonNullRaw}, + StateValue: state, PlanValue: plan, ConfigValue: config, + } + for _, m := range attr.PlanModifiers { + resp := &planmodifier.Int64Response{PlanValue: req.PlanValue} + m.PlanModifyInt64(context.Background(), req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("plan modifier returned diagnostics: %v", resp.Diagnostics) + } + req.PlanValue = resp.PlanValue + } + return req.PlanValue +} + +func runListPlanModifiers(t *testing.T, attr rschema.ListAttribute, state, plan, config types.List) types.List { + t.Helper() + nonNullRaw := tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{}}, map[string]tftypes.Value{}) + return runListPlanModifiersWithStateRaw(t, attr, nonNullRaw, state, plan, config) +} + +func runListPlanModifiersWithStateRaw(t *testing.T, attr rschema.ListAttribute, stateRaw tftypes.Value, state, plan, config types.List) types.List { + t.Helper() + req := planmodifier.ListRequest{ + State: tfsdk.State{Raw: stateRaw}, + StateValue: state, PlanValue: plan, ConfigValue: config, + } + for _, m := range attr.PlanModifiers { + resp := &planmodifier.ListResponse{PlanValue: req.PlanValue} + m.PlanModifyList(context.Background(), req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("plan modifier returned diagnostics: %v", resp.Diagnostics) + } + req.PlanValue = resp.PlanValue + } + return req.PlanValue +} + func TestSchemaValidatesNameAPIContract(t *testing.T) { attr := stringAttribute(t, BrowserPoolSchema(), "name") From 95873514f01aa3c4eca576b23ef743f7e9ccae3d Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:58:33 -0400 Subject: [PATCH 2/2] Simplify browser pool replacement planning --- internal/resources/browserpool/expand.go | 26 ----------- internal/resources/browserpool/expand_test.go | 44 ------------------- internal/resources/browserpool/flatten.go | 6 +-- .../resources/browserpool/flatten_test.go | 7 +-- internal/resources/browserpool/resource.go | 20 +++------ .../resources/browserpool/resource_test.go | 7 +-- 6 files changed, 10 insertions(+), 100 deletions(-) diff --git a/internal/resources/browserpool/expand.go b/internal/resources/browserpool/expand.go index b58c436..19b8640 100644 --- a/internal/resources/browserpool/expand.go +++ b/internal/resources/browserpool/expand.go @@ -102,7 +102,6 @@ func expandUpdateParams(ctx context.Context, plan, state browserPoolModel) (kern ) } validateUpdateKnownValues(&diags, plan) - validateSupportedUpdateClears(&diags, plan, state) if diags.HasError() { return kernel.BrowserPoolUpdateParams{}, false, diags } @@ -336,35 +335,10 @@ func validateUpdateKnownValues(diags *diag.Diagnostics, model browserPoolModel) requireKnownOptional(diags, path.Root("rebuild_idle_browsers_on_update"), model.RebuildIdle, "updating") } -func validateSupportedUpdateClears(diags *diag.Diagnostics, plan, state browserPoolModel) { - if clearsString(plan.Name, state.Name) { - addUnsupportedClearDiagnostic( - diags, - path.Root("name"), - "The Kernel browser pool API does not currently support clearing a browser pool name. Set a new name or keep the existing name.", - ) - } - if plan.Viewport.IsNull() && !state.Viewport.IsNull() && !state.Viewport.IsUnknown() { - addUnsupportedClearDiagnostic( - diags, - path.Root("viewport"), - "The Kernel browser pool API does not currently expose a safe viewport clear payload. Set a new viewport or keep the existing viewport.", - ) - } -} - func clearsString(plan, state types.String) bool { return plan.IsNull() && isKnownString(state) } -func addUnsupportedClearDiagnostic(diags *diag.Diagnostics, attrPath path.Path, detail string) { - diags.AddAttributeError( - attrPath, - "Unsupported Browser Pool Clear", - detail, - ) -} - func requireKnownOptional(diags *diag.Diagnostics, attrPath path.Path, value attr.Value, operation string) { if value.IsNull() || !value.IsUnknown() { return diff --git a/internal/resources/browserpool/expand_test.go b/internal/resources/browserpool/expand_test.go index c56fcf6..cb45dde 100644 --- a/internal/resources/browserpool/expand_test.go +++ b/internal/resources/browserpool/expand_test.go @@ -664,50 +664,6 @@ func TestExpandUpdateParamsClearsOnlyProfile(t *testing.T) { } } -func TestExpandUpdateParamsRejectsUnsupportedClears(t *testing.T) { - plan := browserPoolModel{ - Name: types.StringNull(), - Size: types.Int64Value(1), - ProfileID: types.StringNull(), - ProxyID: types.StringNull(), - ExtensionIDs: types.ListNull(types.StringType), - ChromePolicy: chromePolicyNull(), - Viewport: types.ObjectNull(viewportAttrTypes()), - Headless: types.BoolValue(true), - KioskMode: types.BoolValue(false), - Stealth: types.BoolValue(false), - StartURL: types.StringNull(), - TimeoutSeconds: types.Int64Value(90), - FillRatePerMinute: types.Int64Value(10), - } - state := browserPoolModel{ - Name: types.StringValue("pool-a"), - Size: types.Int64Value(1), - ProfileID: types.StringValue("profile-1"), - ProxyID: types.StringNull(), - ExtensionIDs: types.ListNull(types.StringType), - ChromePolicy: chromePolicyNull(), - Viewport: viewportObjectForTest(types.Int64Value(1280), types.Int64Value(800), types.Int64Null()), - Headless: types.BoolValue(true), - KioskMode: types.BoolValue(false), - Stealth: types.BoolValue(false), - StartURL: types.StringNull(), - TimeoutSeconds: types.Int64Value(90), - FillRatePerMinute: types.Int64Value(10), - } - - params, _, diags := expandUpdateParams(context.Background(), plan, state) - if !diags.HasError() { - t.Fatal("expected diagnostics for unsupported clear operations") - } - for _, want := range []path.Path{path.Root("name"), path.Root("viewport")} { - if !hasDiagnosticPath(diags, want) { - t.Fatalf("expected diagnostic at %s, got %v", want.String(), diags) - } - } - assertEmptyUpdateSDKParams(t, params) -} - func TestExpandUpdateParamsAccumulatesUnknownDiagnostics(t *testing.T) { // Mirrors the create path: an unknown size must not short-circuit the // optional-unknown checks, so every problem surfaces in one apply cycle. diff --git a/internal/resources/browserpool/flatten.go b/internal/resources/browserpool/flatten.go index a94cafa..8561639 100644 --- a/internal/resources/browserpool/flatten.go +++ b/internal/resources/browserpool/flatten.go @@ -79,11 +79,7 @@ func flattenResolvedProfileID(pool kernel.BrowserPool, config kernel.BrowserPool func flattenResolvedExtensionIDs(pool kernel.BrowserPool, config kernel.BrowserPoolBrowserPoolConfig, base types.List, diags *diag.Diagnostics) types.List { raw := pool.JSON.ExtensionIDs.Raw() if raw != "" { - resolved := flattenStringList("extension_ids", raw, pool.JSON.ExtensionIDs.Valid(), pool.ExtensionIDs, diags) - if !resolved.IsNull() && len(pool.ExtensionIDs) == 0 { - return omittedExtensionIDs("", base) - } - return resolved + return flattenStringList("extension_ids", raw, pool.JSON.ExtensionIDs.Valid(), pool.ExtensionIDs, diags) } if responseFieldPresent(config.JSON.Extensions.Raw()) { return flattenExtensionIDs(config.JSON.Extensions.Valid(), config.Extensions, diags) diff --git a/internal/resources/browserpool/flatten_test.go b/internal/resources/browserpool/flatten_test.go index 0d84e5a..a2336d0 100644 --- a/internal/resources/browserpool/flatten_test.go +++ b/internal/resources/browserpool/flatten_test.go @@ -162,9 +162,7 @@ func TestFlattenBrowserPoolNullsOmittedOptionalFields(t *testing.T) { assertStringNull(t, "name", got.Name) assertStringNull(t, "profile_id", got.ProfileID) assertStringNull(t, "proxy_id", got.ProxyID) - if !got.ExtensionIDs.IsNull() || got.ExtensionIDs.ElementType(t.Context()) != types.StringType { - t.Fatalf("extension_ids = %#v, want typed string list null", got.ExtensionIDs) - } + assertStringList(t, got.ExtensionIDs, []string{}) if !got.ChromePolicy.IsNull() { t.Fatalf("chrome_policy = %#v, want null", got.ChromePolicy) } @@ -189,7 +187,7 @@ func TestFlattenBrowserPoolNullsOmittedOptionalFields(t *testing.T) { } } -func TestFlattenBrowserPoolPreservesExplicitEmptyConfigWhenResolvedIDsAreEmpty(t *testing.T) { +func TestFlattenBrowserPoolUsesResolvedEmptyExtensionIDs(t *testing.T) { pool := unmarshalBrowserPool(t, `{ "id": "pool-1", "extension_ids": [], @@ -198,7 +196,6 @@ func TestFlattenBrowserPoolPreservesExplicitEmptyConfigWhenResolvedIDsAreEmpty(t } }`) base := browserPoolModel{ - ExtensionIDs: stringListForTest(), ChromePolicy: chromePolicyValueForTest(`{}`), } diff --git a/internal/resources/browserpool/resource.go b/internal/resources/browserpool/resource.go index 0380b65..9a71965 100644 --- a/internal/resources/browserpool/resource.go +++ b/internal/resources/browserpool/resource.go @@ -10,7 +10,6 @@ import ( "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/types" kernel "github.com/kernel/kernel-go-sdk" "github.com/kernel/terraform-provider-kernel/internal/projectscope" ) @@ -64,13 +63,14 @@ func (r *browserPoolResource) ModifyPlan(ctx context.Context, req resource.Modif if resp.Diagnostics.HasError() { return } - if browserPoolReplacementRequired(plan, state) { + replacementRequired := browserPoolReplacementRequired(plan, state) + if replacementRequired { resp.Diagnostics.AddWarning( "Browser Pool Will Be Replaced", "Applying this plan will replace the browser pool. Completing the replacement deletes the existing pool and all browsers in it. Kernel blocks this provider's non-forceful deletion while any browser is leased; release leased browsers before applying.", ) } - if !idleBrowserRebuildWarningRequired(plan, state, config) { + if replacementRequired || !idleBrowserRebuildWarningRequired(plan, state, config) { return } @@ -82,7 +82,8 @@ func (r *browserPoolResource) ModifyPlan(ctx context.Context, req resource.Modif } func browserPoolReplacementRequired(plan, state browserPoolModel) bool { - // Keep these conditions aligned with the schema's replacement plan modifiers. + // The Framework does not expose schema-level replacement paths to the + // resource-level ModifyPlan response, so mirror them here for the warning. projectChanges := !plan.ProjectID.IsUnknown() && !plan.ProjectID.Equal(state.ProjectID) clearsViewport := plan.Viewport.IsNull() && !state.Viewport.IsNull() && !state.Viewport.IsUnknown() return projectChanges || clearsString(plan.Name, state.Name) || clearsViewport @@ -95,16 +96,6 @@ func idleBrowserRebuildWarningRequired(plan, state, config browserPoolModel) boo if !rebuildPossible { return false } - if !plan.ProjectID.IsUnknown() && !plan.ProjectID.Equal(state.ProjectID) { - return false - } - - var diags diag.Diagnostics - validateSupportedUpdateClears(&diags, plan, state) - if diags.HasError() { - return false - } - return browserLaunchConfigurationMayChange(plan, state, config) } @@ -216,7 +207,6 @@ func (r *browserPoolResource) ImportState(ctx context.Context, req resource.Impo resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), poolID)...) resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectscope.StateValue(projectID))...) - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("extension_ids"), types.ListValueMust(types.StringType, nil))...) } func parseImportID(id string) (projectID, poolID string, ok bool) { diff --git a/internal/resources/browserpool/resource_test.go b/internal/resources/browserpool/resource_test.go index a7d51b0..bc70b00 100644 --- a/internal/resources/browserpool/resource_test.go +++ b/internal/resources/browserpool/resource_test.go @@ -188,7 +188,7 @@ func TestModifyPlanWarnings(t *testing.T) { wantReplacementWarn: true, }, { - name: "unsupported name clear blocks launch update", + name: "name clear replacement suppresses idle rebuild warning", apply: func(plan *browserPoolModel) { plan.Name = types.StringNull() plan.Stealth = types.BoolValue(true) @@ -197,7 +197,7 @@ func TestModifyPlanWarnings(t *testing.T) { wantReplacementWarn: true, }, { - name: "unsupported viewport clear", + name: "viewport clear replacement", apply: func(plan *browserPoolModel) { plan.Viewport = types.ObjectNull(plan.Viewport.AttributeTypes(context.Background())) }, @@ -384,8 +384,6 @@ func TestResourceImportState(t *testing.T) { resp.Diagnostics.Append(resp.State.GetAttribute(ctx, path.Root("id"), &id)...) var projectID types.String resp.Diagnostics.Append(resp.State.GetAttribute(ctx, path.Root("project_id"), &projectID)...) - var extensionIDs types.List - resp.Diagnostics.Append(resp.State.GetAttribute(ctx, path.Root("extension_ids"), &extensionIDs)...) if resp.Diagnostics.HasError() { t.Fatalf("read imported state: %v", resp.Diagnostics) } @@ -395,7 +393,6 @@ func TestResourceImportState(t *testing.T) { if !projectID.Equal(test.wantProjectID) { t.Fatalf("imported project_id = %v, want %v", projectID, test.wantProjectID) } - assertStringList(t, extensionIDs, []string{}) }) } }