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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,10 @@ export KERNEL_PROJECT_ID="..."
```

The tests create uniquely named durable resources and register independent
cleanup. Browser-pool deletion remains `force=false`. The tests do not acquire,
release, or recover browsers.
cleanup. Browser-pool deletion remains `force=false`. One update regression
opts into rebuilding idle browsers, acquires a replacement, and releases it
with `reuse=false`; the remaining tests do not acquire, release, or recover
browsers.

Use the commands in the [selected-surface acceptance matrix](docs/acceptance.md).
It is the source of truth for current live coverage and the pre-tag release run.
Expand Down
6 changes: 4 additions & 2 deletions docs/acceptance.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ sources. It does not claim coverage for future or unregistered Kernel objects.
- Register cleanup as soon as a canonical ID exists.
- Verify deletion through a follow-up API read.
- Keep browser-pool deletion non-forceful.
- Never acquire, release, flush, invoke, or recover runtime state.
- Do not exercise runtime state except for the browser-pool update regression,
which explicitly enables `rebuild_idle_browsers_on_update`, acquires one
replacement, and releases it with `reuse=false`.
- Keep live tests out of pull-request CI.
- Run the complete matrix against the release commit before tagging.

Expand All @@ -37,7 +39,7 @@ sources. The project resource is organization-scoped and does not require it.
| Surface | Package | Live scenario |
| --- | --- | --- |
| `kernel_project` resource | `./internal/resources/project` | Create, rename with stable ID, no-drift plan, canonical-ID import, post-import no drift, delete, and HTTP 404 verification. |
| `kernel_browser_pool` resource | `./internal/resources/browserpool` | Create, durable update with stable ID, no-drift plan, provider-default and explicit project scope, bare and project-qualified import, non-force delete, and HTTP 404 verification. |
| `kernel_browser_pool` resource | `./internal/resources/browserpool` | Create, durable update with stable ID, opt into rebuilding and acquire an idle browser after a configuration change, no-drift plan, provider-default and explicit project scope, bare and project-qualified import, non-force delete, and HTTP 404 verification. |
| `kernel_project` data source | `./internal/datasources/project` | Create a unique project fixture, read it by ID and exact name, read the provider-default project, verify durable metadata and no drift, then delete and require coded `not_found`. |
| `kernel_profile` data source | `./internal/datasources/profile` | Create a durable profile fixture through the SDK, read it by ID and exact name with explicit and default project scope, verify durable metadata and no drift, then delete and require coded `not_found`. |
| `kernel_proxy` data source | `./internal/datasources/proxy` | Create a managed datacenter proxy fixture through the SDK, read it by ID and exact name with explicit and default project scope, verify durable masked metadata and no drift, then delete and require coded `not_found`. |
Expand Down
1 change: 1 addition & 0 deletions docs/resources/browser_pool.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Kernel browser pool durable configuration.
- `profile_id` (String) Optional profile ID to load for browsers created by this pool.
- `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.
Expand Down
142 changes: 135 additions & 7 deletions internal/resources/browserpool/expand.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ func expandCreateParams(ctx context.Context, model browserPoolModel) (kernel.Bro
return params, diags
}

func expandUpdateParams(ctx context.Context, plan, state browserPoolModel) (kernel.BrowserPoolUpdateParams, diag.Diagnostics) {
func expandUpdateParams(ctx context.Context, plan, state browserPoolModel) (kernel.BrowserPoolUpdateParams, bool, diag.Diagnostics) {
var diags diag.Diagnostics

// Collect every known-value problem before bailing so a plan with several
Expand All @@ -104,83 +104,210 @@ func expandUpdateParams(ctx context.Context, plan, state browserPoolModel) (kern
validateUpdateKnownValues(&diags, plan)
validateSupportedUpdateClears(&diags, plan, state)
if diags.HasError() {
return kernel.BrowserPoolUpdateParams{}, diags
return kernel.BrowserPoolUpdateParams{}, false, diags
}

var params kernel.BrowserPoolUpdateParams
hasPatch := false
if isKnownBool(plan.RebuildIdle) && plan.RebuildIdle.ValueBool() && browserLaunchConfigurationChanged(plan, state) {
// Pool updates change the template for future browsers. Rebuild browsers
// that are idle now only when the customer explicitly opts into the
// disruptive replacement behavior.
params.DiscardAllIdle = kernel.Bool(true)
hasPatch = true
}
Comment thread
cursor[bot] marked this conversation as resolved.

if !plan.Name.Equal(state.Name) && isKnownString(plan.Name) {
params.Name = kernel.String(plan.Name.ValueString())
hasPatch = true
}
if !plan.Size.Equal(state.Size) {
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.ProxyID.Equal(state.ProxyID) {
if plan.ProxyID.IsNull() {
params.ProxyID = kernel.String("")
hasPatch = true
} else if isKnownString(plan.ProxyID) {
params.ProxyID = kernel.String(plan.ProxyID.ValueString())
hasPatch = true
}
}
if !plan.ExtensionIDs.Equal(state.ExtensionIDs) {
if plan.ExtensionIDs.IsNull() {
params.Extensions = []shared.BrowserExtensionParam{}
hasPatch = true
} else {
ids, extensionDiags := extensionIDs(ctx, plan.ExtensionIDs, "updating")
diags.Append(extensionDiags...)
if diags.HasError() {
return kernel.BrowserPoolUpdateParams{}, diags
return kernel.BrowserPoolUpdateParams{}, false, diags
}
params.Extensions = extensionParams(ids)
hasPatch = true
}
}
if !plan.ChromePolicy.Equal(state.ChromePolicy) {
if plan.ChromePolicy.IsNull() {
params.ChromePolicy = map[string]any{}
hasPatch = true
} else if isKnownString(plan.ChromePolicy.StringValue) {
policy, policyDiags := decodeChromePolicyJSON(plan.ChromePolicy.ValueString())
diags.Append(policyDiags...)
if diags.HasError() {
return kernel.BrowserPoolUpdateParams{}, diags
return kernel.BrowserPoolUpdateParams{}, false, diags
}
params.ChromePolicy = policy
hasPatch = true
}
}
if !plan.Viewport.Equal(state.Viewport) && !plan.Viewport.IsNull() {
if browserViewportChanged(plan.Viewport, state.Viewport) && !plan.Viewport.IsNull() {
viewport, viewportDiags := expandViewport(ctx, plan.Viewport)
diags.Append(viewportDiags...)
if diags.HasError() {
return kernel.BrowserPoolUpdateParams{}, diags
return kernel.BrowserPoolUpdateParams{}, false, diags
}
params.Viewport = viewport
hasPatch = true
}
if !plan.Headless.Equal(state.Headless) && isKnownBool(plan.Headless) {
params.Headless = kernel.Bool(plan.Headless.ValueBool())
hasPatch = true
}
if !plan.KioskMode.Equal(state.KioskMode) && isKnownBool(plan.KioskMode) {
params.KioskMode = kernel.Bool(plan.KioskMode.ValueBool())
hasPatch = true
}
if !plan.Stealth.Equal(state.Stealth) && isKnownBool(plan.Stealth) {
params.Stealth = kernel.Bool(plan.Stealth.ValueBool())
hasPatch = true
}
if !plan.StartURL.Equal(state.StartURL) {
if plan.StartURL.IsNull() {
params.StartURL = kernel.String("")
hasPatch = true
} else if isKnownString(plan.StartURL) {
params.StartURL = kernel.String(plan.StartURL.ValueString())
hasPatch = true
}
}
if !plan.TimeoutSeconds.Equal(state.TimeoutSeconds) && isKnownInt64(plan.TimeoutSeconds) {
params.TimeoutSeconds = kernel.Int(plan.TimeoutSeconds.ValueInt64())
hasPatch = true
}
if !plan.FillRatePerMinute.Equal(state.FillRatePerMinute) && isKnownInt64(plan.FillRatePerMinute) {
params.FillRatePerMinute = kernel.Int(plan.FillRatePerMinute.ValueInt64())
hasPatch = true
}

return params, diags
return params, hasPatch, diags
}

func browserLaunchConfigurationChanged(plan, state browserPoolModel) bool {
// Keep raw Equal checks aligned with validateUpdateKnownValues and all fields
// aligned with the patch builder above. Otherwise an unknown planned value
// could discard idle browsers without a corresponding configuration patch.
// Optional+Computed booleans use knownBoolChanged instead.
return !plan.ProfileID.Equal(state.ProfileID) ||
!plan.ProxyID.Equal(state.ProxyID) ||
!plan.ExtensionIDs.Equal(state.ExtensionIDs) ||
!plan.ChromePolicy.Equal(state.ChromePolicy) ||
browserViewportChanged(plan.Viewport, state.Viewport) ||
knownBoolChanged(plan.Headless, state.Headless) ||
knownBoolChanged(plan.KioskMode, state.KioskMode) ||
knownBoolChanged(plan.Stealth, state.Stealth) ||
!plan.StartURL.Equal(state.StartURL)
}
Comment thread
cursor[bot] marked this conversation as resolved.

func browserLaunchConfigurationMayChange(plan, state, config browserPoolModel) bool {
return knownValueChanged(plan.ProfileID, state.ProfileID) ||
knownValueChanged(plan.ProxyID, state.ProxyID) ||
knownValueChanged(plan.ExtensionIDs, state.ExtensionIDs) ||
knownValueChanged(plan.ChromePolicy, state.ChromePolicy) ||
knownBrowserViewportChanged(plan.Viewport, state.Viewport) ||
knownBoolChanged(plan.Headless, state.Headless) ||
knownBoolChanged(plan.KioskMode, state.KioskMode) ||
knownBoolChanged(plan.Stealth, state.Stealth) ||
knownValueChanged(plan.StartURL, state.StartURL) ||
browserLaunchConfigurationUnknown(config)
}

func knownValueChanged(plan, state attr.Value) bool {
return !plan.IsUnknown() && !plan.Equal(state)
}

func browserLaunchConfigurationUnknown(config browserPoolModel) bool {
if config.ProfileID.IsUnknown() ||
config.ProxyID.IsUnknown() ||
config.ExtensionIDs.IsUnknown() ||
config.ChromePolicy.IsUnknown() ||
config.Headless.IsUnknown() ||
config.KioskMode.IsUnknown() ||
config.Stealth.IsUnknown() ||
config.StartURL.IsUnknown() {
return true
}
if config.Viewport.IsNull() {
return false
}
if config.Viewport.IsUnknown() {
return true
}
for _, value := range config.Viewport.Attributes() {
if value.IsUnknown() {
return true
}
}
return false
}

func knownBoolChanged(plan, state types.Bool) bool {
// Optional+Computed values can legitimately be unknown during planning.
// Unknown means "not decided yet", not "different from state".
return isKnownBool(plan) && !plan.Equal(state)
}

func browserViewportChanged(plan, state types.Object) bool {
if plan.IsUnknown() {
return false
}
if plan.IsNull() || state.IsNull() || state.IsUnknown() {
return !plan.Equal(state)
}

planAttrs := plan.Attributes()
stateAttrs := state.Attributes()
for _, name := range []string{"width", "height"} {
if !planAttrs[name].Equal(stateAttrs[name]) {
return true
}
}

planRefreshRate := planAttrs["refresh_rate"].(types.Int64)
return !planRefreshRate.IsUnknown() && !planRefreshRate.Equal(stateAttrs["refresh_rate"])
}

func knownBrowserViewportChanged(plan, state types.Object) bool {
if plan.IsUnknown() {
return false
}
if plan.IsNull() || state.IsNull() || state.IsUnknown() {
return !plan.Equal(state)
}

planAttrs := plan.Attributes()
stateAttrs := state.Attributes()
for _, name := range []string{"width", "height", "refresh_rate"} {
if !planAttrs[name].IsUnknown() && !planAttrs[name].Equal(stateAttrs[name]) {
return true
}
}
return false
}

func validateCreateKnownValues(diags *diag.Diagnostics, model browserPoolModel) {
Expand All @@ -201,6 +328,7 @@ func validateUpdateKnownValues(diags *diag.Diagnostics, model browserPoolModel)
requireKnownOptional(diags, path.Root("extension_ids"), model.ExtensionIDs, "updating")
requireKnownOptional(diags, path.Root("viewport"), model.Viewport, "updating")
requireKnownOptional(diags, path.Root("start_url"), model.StartURL, "updating")
requireKnownOptional(diags, path.Root("rebuild_idle_browsers_on_update"), model.RebuildIdle, "updating")
}

func validateSupportedUpdateClears(diags *diag.Diagnostics, plan, state browserPoolModel) {
Expand Down
Loading