Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions docs/resources/browser_pool.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
38 changes: 38 additions & 0 deletions internal/resources/browserpool/chrome_policy_plan_modifier.go
Original file line number Diff line number Diff line change
@@ -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
}
}
44 changes: 8 additions & 36 deletions internal/resources/browserpool/expand.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -125,9 +124,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() {
Expand Down Expand Up @@ -331,42 +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 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,
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
Expand Down
56 changes: 17 additions & 39 deletions internal/resources/browserpool/expand_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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{},
Expand All @@ -642,48 +643,25 @@ func TestExpandUpdateParamsClearsSupportedDurableConfig(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),
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)
}
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),
if !hasPatch {
t.Fatal("clearing profile_id must produce an API patch")
}

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("profile_id"), path.Root("viewport")} {
if !hasDiagnosticPath(diags, want) {
t.Fatalf("expected diagnostic at %s, got %v", want.String(), diags)
}
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)
}
assertEmptyUpdateSDKParams(t, params)
}

func TestExpandUpdateParamsAccumulatesUnknownDiagnostics(t *testing.T) {
Expand Down
30 changes: 30 additions & 0 deletions internal/resources/browserpool/extension_ids_plan_modifier.go
Original file line number Diff line number Diff line change
@@ -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)
}
6 changes: 1 addition & 5 deletions internal/resources/browserpool/flatten.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 2 additions & 5 deletions internal/resources/browserpool/flatten_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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": [],
Expand All @@ -198,7 +196,6 @@ func TestFlattenBrowserPoolPreservesExplicitEmptyConfigWhenResolvedIDsAreEmpty(t
}
}`)
base := browserPoolModel{
ExtensionIDs: stringListForTest(),
ChromePolicy: chromePolicyValueForTest(`{}`),
}

Expand Down
30 changes: 19 additions & 11 deletions internal/resources/browserpool/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,17 @@ 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
}
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 replacementRequired || !idleBrowserRebuildWarningRequired(plan, state, config) {
return
}

Expand All @@ -71,23 +81,21 @@ func (r *browserPoolResource) ModifyPlan(ctx context.Context, req resource.Modif
)
}

func browserPoolReplacementRequired(plan, state browserPoolModel) bool {
// 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
}

func idleBrowserRebuildWarningRequired(plan, state, config browserPoolModel) bool {
rebuildPossible := isKnownBool(plan.RebuildIdle) && plan.RebuildIdle.ValueBool()
rebuildPossible = rebuildPossible || config.RebuildIdle.IsUnknown() ||
(isKnownBool(config.RebuildIdle) && config.RebuildIdle.ValueBool())
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)
}

Expand Down
Loading