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: 5 additions & 1 deletion .github/workflows/acceptance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,8 @@ jobs:
KERNEL_BASE_URL: ${{ secrets.KERNEL_BASE_URL }}
KERNEL_PROJECT_ID: ${{ matrix.project_id_required && secrets.KERNEL_PROJECT_ID || '' }}
KERNEL_ALT_PROJECT_ID: ${{ matrix.project_id_required && secrets.KERNEL_ALT_PROJECT_ID || '' }}
run: go test -count=1 -timeout=30m -v ${{ matrix.package }} -run TestAcc
run: |
if [ -z "$KERNEL_BASE_URL" ]; then
unset KERNEL_BASE_URL
fi
go test -count=1 -timeout=30m -v ${{ matrix.package }} -run TestAcc
6 changes: 6 additions & 0 deletions docs/acceptance.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ sources. The project resource is organization-scoped and does not require it.
The tests exist in the repository. That does not prove they passed against a
particular release commit; the release record supplies that evidence.

Browser-pool acceptance does not yet exercise Kernel's conditional default for
`refresh_on_profile_update`. A future live test should create durable profile
fixtures through the SDK and cover attaching a profile, changing profiles, and
clearing the profile while the attribute is omitted. Unit and fake-API tests
cover those transitions today.

## Commands

Run packages independently for fast failure isolation:
Expand Down
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ Durable fields include:
- `name`
- `size`
- `profile_id`
- `refresh_on_profile_update`
- `proxy_id`
- ordered `extension_ids`
- `chrome_policy`
Expand Down
3 changes: 2 additions & 1 deletion docs/resources/browser_pool.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ Kernel browser pool durable configuration.
- `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.
- `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. This does not control later profile-content updates; use `refresh_on_profile_update` for that. Kernel does not store this provider-local setting, so imported browser pools default to false unless configured otherwise.
- `refresh_on_profile_update` (Boolean) Controls whether idle browsers are refreshed when the pool's profile is updated. Requires `profile_id` when true. When omitted, Kernel chooses the applicable default when a profile is attached, changed, or removed; the API value is stored in state and preserved during unrelated updates. Explicit true or false values are sent unchanged. This is separate from `rebuild_idle_browsers_on_update`, which handles launch-configuration changes made through this Terraform resource.
- `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
11 changes: 10 additions & 1 deletion internal/resources/browserpool/expand.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ func expandCreateParams(ctx context.Context, model browserPoolModel) (kernel.Bro
if isKnownString(model.ProfileID) {
params.Profile.ID = kernel.String(model.ProfileID.ValueString())
}
if isKnownBool(model.RefreshOnProfileUpdate) {
params.RefreshOnProfileUpdate = kernel.Bool(model.RefreshOnProfileUpdate.ValueBool())
}
if isKnownString(model.ProxyID) {
params.ProxyID = kernel.String(model.ProxyID.ValueString())
}
Expand Down Expand Up @@ -124,7 +127,8 @@ func expandUpdateParams(ctx context.Context, plan, state browserPoolModel) (kern
params.Size = kernel.Int(plan.Size.ValueInt64())
hasPatch = true
}
if !plan.ProfileID.Equal(state.ProfileID) {
profileChanged := !plan.ProfileID.Equal(state.ProfileID)
if profileChanged {
if plan.ProfileID.IsNull() {
params.Profile.ID = kernel.String("")
hasPatch = true
Expand All @@ -133,6 +137,11 @@ func expandUpdateParams(ctx context.Context, plan, state browserPoolModel) (kern
hasPatch = true
}
}
// An unknown value on a profile change is omitted so Kernel can choose the applicable default.
if (!plan.RefreshOnProfileUpdate.Equal(state.RefreshOnProfileUpdate) || profileChanged) && isKnownBool(plan.RefreshOnProfileUpdate) {
params.RefreshOnProfileUpdate = kernel.Bool(plan.RefreshOnProfileUpdate.ValueBool())
hasPatch = true
}
if !plan.ProxyID.Equal(state.ProxyID) {
if plan.ProxyID.IsNull() {
params.ProxyID = kernel.String("")
Expand Down
188 changes: 156 additions & 32 deletions internal/resources/browserpool/expand_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,20 @@ import (

func TestExpandCreateParamsMapsDurableConfigToSDK(t *testing.T) {
model := browserPoolModel{
Name: types.StringValue("pool-a"),
Size: types.Int64Value(5),
ProfileID: types.StringValue("profile-1"),
ProxyID: types.StringValue("proxy-1"),
ExtensionIDs: stringListForTest("ext-b", "ext-a"),
ChromePolicy: chromePolicyValueForTest(`{"HomepageLocation":"https://example.com"}`),
Viewport: viewportObjectForTest(types.Int64Value(1280), types.Int64Value(800), types.Int64Value(60)),
Headless: types.BoolValue(true),
KioskMode: types.BoolValue(true),
Stealth: types.BoolValue(false),
StartURL: types.StringValue("https://start.example"),
TimeoutSeconds: types.Int64Value(90),
FillRatePerMinute: types.Int64Value(20),
Name: types.StringValue("pool-a"),
Size: types.Int64Value(5),
ProfileID: types.StringValue("profile-1"),
RefreshOnProfileUpdate: types.BoolValue(false),
ProxyID: types.StringValue("proxy-1"),
ExtensionIDs: stringListForTest("ext-b", "ext-a"),
ChromePolicy: chromePolicyValueForTest(`{"HomepageLocation":"https://example.com"}`),
Viewport: viewportObjectForTest(types.Int64Value(1280), types.Int64Value(800), types.Int64Value(60)),
Headless: types.BoolValue(true),
KioskMode: types.BoolValue(true),
Stealth: types.BoolValue(false),
StartURL: types.StringValue("https://start.example"),
TimeoutSeconds: types.Int64Value(90),
FillRatePerMinute: types.Int64Value(20),
}

params, diags := expandCreateParams(context.Background(), model)
Expand All @@ -38,19 +39,20 @@ func TestExpandCreateParamsMapsDurableConfigToSDK(t *testing.T) {

body := marshalSDKParams(t, params)
want := map[string]any{
"name": "pool-a",
"size": float64(5),
"profile": map[string]any{"id": "profile-1"},
"proxy_id": "proxy-1",
"extensions": []any{map[string]any{"id": "ext-b"}, map[string]any{"id": "ext-a"}},
"chrome_policy": map[string]any{"HomepageLocation": "https://example.com"},
"viewport": map[string]any{"width": float64(1280), "height": float64(800), "refresh_rate": float64(60)},
"headless": true,
"kiosk_mode": true,
"stealth": false,
"start_url": "https://start.example",
"timeout_seconds": float64(90),
"fill_rate_per_minute": float64(20),
"name": "pool-a",
"size": float64(5),
"profile": map[string]any{"id": "profile-1"},
"refresh_on_profile_update": false,
"proxy_id": "proxy-1",
"extensions": []any{map[string]any{"id": "ext-b"}, map[string]any{"id": "ext-a"}},
"chrome_policy": map[string]any{"HomepageLocation": "https://example.com"},
"viewport": map[string]any{"width": float64(1280), "height": float64(800), "refresh_rate": float64(60)},
"headless": true,
"kiosk_mode": true,
"stealth": false,
"start_url": "https://start.example",
"timeout_seconds": float64(90),
"fill_rate_per_minute": float64(20),
}

if !jsonEqual(t, body, want) {
Expand All @@ -60,12 +62,13 @@ func TestExpandCreateParamsMapsDurableConfigToSDK(t *testing.T) {

func TestExpandCreateParamsOmitsUnknownServerDefaults(t *testing.T) {
model := browserPoolModel{
Size: types.Int64Value(1),
Headless: types.BoolUnknown(),
KioskMode: types.BoolUnknown(),
Stealth: types.BoolUnknown(),
TimeoutSeconds: types.Int64Unknown(),
FillRatePerMinute: types.Int64Unknown(),
Size: types.Int64Value(1),
RefreshOnProfileUpdate: types.BoolUnknown(),
Headless: types.BoolUnknown(),
KioskMode: types.BoolUnknown(),
Stealth: types.BoolUnknown(),
TimeoutSeconds: types.Int64Unknown(),
FillRatePerMinute: types.Int64Unknown(),
}

params, diags := expandCreateParams(context.Background(), model)
Expand Down Expand Up @@ -401,6 +404,127 @@ func TestExpandUpdateParamsDoesNotRebuildIdleBrowsersWithoutLaunchChanges(t *tes
assertEmptyUpdateSDKParams(t, params)
}

func TestExpandUpdateParamsMapsRefreshOnProfileUpdateChanges(t *testing.T) {
tests := map[string]struct {
plan types.Bool
state types.Bool
want bool
}{
"enable": {plan: types.BoolValue(true), state: types.BoolValue(false), want: true},
"disable": {plan: types.BoolValue(false), state: types.BoolValue(true), want: false},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {
params, hasPatch, diags := expandUpdateParams(
context.Background(),
refreshOnProfileUpdateModel(test.plan),
refreshOnProfileUpdateModel(test.state),
)
if diags.HasError() {
t.Fatalf("unexpected diagnostics: %v", diags)
}
if !hasPatch {
t.Fatal("refresh_on_profile_update change did not produce an API patch")
}

body := marshalSDKParams(t, params)
want := map[string]any{"refresh_on_profile_update": test.want}
if !jsonEqual(t, body, want) {
t.Fatalf("expanded SDK JSON mismatch\ngot: %#v\nwant: %#v", body, want)
}
})
}
}

func TestExpandUpdateParamsOmitsUnchangedRefreshOnProfileUpdate(t *testing.T) {
model := refreshOnProfileUpdateModel(types.BoolValue(true))

params, hasPatch, diags := expandUpdateParams(context.Background(), model, model)
if diags.HasError() {
t.Fatalf("unexpected diagnostics: %v", diags)
}
if hasPatch {
t.Fatal("unchanged refresh_on_profile_update produced an API patch")
}
assertEmptyUpdateSDKParams(t, params)
}

func TestExpandUpdateParamsPreservesExplicitRefreshWhenProfileChanges(t *testing.T) {
plan := refreshOnProfileUpdateModel(types.BoolValue(false))
plan.ProfileID = types.StringValue("profile-2")
state := refreshOnProfileUpdateModel(types.BoolValue(false))
state.ProfileID = types.StringValue("profile-1")

params, hasPatch, diags := expandUpdateParams(context.Background(), plan, state)
if diags.HasError() {
t.Fatalf("unexpected diagnostics: %v", diags)
}
if !hasPatch {
t.Fatal("profile change did not produce an API patch")
}

body := marshalSDKParams(t, params)
want := map[string]any{
"profile": map[string]any{"id": "profile-2"},
"refresh_on_profile_update": false,
}
if !jsonEqual(t, body, want) {
t.Fatalf("expanded SDK JSON mismatch\ngot: %#v\nwant: %#v", body, want)
}
}

func TestExpandUpdateParamsPreservesExplicitRefreshWhenProfileIsRemoved(t *testing.T) {
plan := refreshOnProfileUpdateModel(types.BoolValue(false))
plan.ProfileID = types.StringNull()
state := refreshOnProfileUpdateModel(types.BoolValue(false))
state.ProfileID = types.StringValue("profile-1")

params, hasPatch, diags := expandUpdateParams(context.Background(), plan, state)
if diags.HasError() {
t.Fatalf("unexpected diagnostics: %v", diags)
}
if !hasPatch {
t.Fatal("profile removal did not produce an API patch")
}

body := marshalSDKParams(t, params)
want := map[string]any{
"profile": map[string]any{"id": ""},
"refresh_on_profile_update": false,
}
if !jsonEqual(t, body, want) {
t.Fatalf("expanded SDK JSON mismatch\ngot: %#v\nwant: %#v", body, want)
}
}

func TestExpandUpdateParamsLetsAPIChooseRefreshDefaultWhenProfileChanges(t *testing.T) {
plan := refreshOnProfileUpdateModel(types.BoolUnknown())
plan.ProfileID = types.StringValue("profile-2")
state := refreshOnProfileUpdateModel(types.BoolValue(false))
state.ProfileID = types.StringValue("profile-1")

params, hasPatch, diags := expandUpdateParams(context.Background(), plan, state)
if diags.HasError() {
t.Fatalf("unexpected diagnostics: %v", diags)
}
if !hasPatch {
t.Fatal("profile change did not produce an API patch")
}

body := marshalSDKParams(t, params)
want := map[string]any{"profile": map[string]any{"id": "profile-2"}}
if !jsonEqual(t, body, want) {
t.Fatalf("expanded SDK JSON mismatch\ngot: %#v\nwant: %#v", body, want)
}
}

func refreshOnProfileUpdateModel(value types.Bool) browserPoolModel {
model := updateModelForTest()
model.RefreshOnProfileUpdate = value
return model
}

func TestBrowserLaunchConfigurationChangedForEachLaunchField(t *testing.T) {
state := browserPoolModel{
ProfileID: types.StringValue("profile-1"),
Expand Down
31 changes: 16 additions & 15 deletions internal/resources/browserpool/flatten.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,22 @@ func flattenBrowserPool(pool kernel.BrowserPool, base browserPoolModel) (browser
}

model := browserPoolModel{
ID: types.StringValue(pool.ID),
Name: flattenName(pool, &diags),
Size: types.Int64Value(config.Size),
ProfileID: flattenResolvedProfileID(pool, config, &diags),
ProxyID: flattenString("browser_pool_config.proxy_id", config.JSON.ProxyID.Raw(), config.JSON.ProxyID.Valid(), config.ProxyID, &diags),
ExtensionIDs: flattenResolvedExtensionIDs(pool, config, base.ExtensionIDs, &diags),
ChromePolicy: omittedChromePolicy(config.JSON.ChromePolicy.Raw(), base.ChromePolicy),
Viewport: types.ObjectNull(viewportAttrTypes()),
Headless: flattenBool("browser_pool_config.headless", config.JSON.Headless.Raw(), config.JSON.Headless.Valid(), config.Headless, &diags),
KioskMode: flattenBool("browser_pool_config.kiosk_mode", config.JSON.KioskMode.Raw(), config.JSON.KioskMode.Valid(), config.KioskMode, &diags),
Stealth: flattenBool("browser_pool_config.stealth", config.JSON.Stealth.Raw(), config.JSON.Stealth.Valid(), config.Stealth, &diags),
StartURL: flattenString("browser_pool_config.start_url", config.JSON.StartURL.Raw(), config.JSON.StartURL.Valid(), config.StartURL, &diags),
TimeoutSeconds: flattenTimeoutSeconds(config.JSON.TimeoutSeconds.Raw(), config.JSON.TimeoutSeconds.Valid(), config.TimeoutSeconds, &diags),
FillRatePerMinute: flattenFillRatePerMinute(config.JSON.FillRatePerMinute.Raw(), config.JSON.FillRatePerMinute.Valid(), config.FillRatePerMinute, &diags),
RebuildIdle: rebuildIdle,
ID: types.StringValue(pool.ID),
Name: flattenName(pool, &diags),
Size: types.Int64Value(config.Size),
ProfileID: flattenResolvedProfileID(pool, config, &diags),
RefreshOnProfileUpdate: flattenBool("browser_pool_config.refresh_on_profile_update", config.JSON.RefreshOnProfileUpdate.Raw(), config.JSON.RefreshOnProfileUpdate.Valid(), config.RefreshOnProfileUpdate, &diags),
ProxyID: flattenString("browser_pool_config.proxy_id", config.JSON.ProxyID.Raw(), config.JSON.ProxyID.Valid(), config.ProxyID, &diags),
ExtensionIDs: flattenResolvedExtensionIDs(pool, config, base.ExtensionIDs, &diags),
ChromePolicy: omittedChromePolicy(config.JSON.ChromePolicy.Raw(), base.ChromePolicy),
Viewport: types.ObjectNull(viewportAttrTypes()),
Headless: flattenBool("browser_pool_config.headless", config.JSON.Headless.Raw(), config.JSON.Headless.Valid(), config.Headless, &diags),
KioskMode: flattenBool("browser_pool_config.kiosk_mode", config.JSON.KioskMode.Raw(), config.JSON.KioskMode.Valid(), config.KioskMode, &diags),
Stealth: flattenBool("browser_pool_config.stealth", config.JSON.Stealth.Raw(), config.JSON.Stealth.Valid(), config.Stealth, &diags),
StartURL: flattenString("browser_pool_config.start_url", config.JSON.StartURL.Raw(), config.JSON.StartURL.Valid(), config.StartURL, &diags),
TimeoutSeconds: flattenTimeoutSeconds(config.JSON.TimeoutSeconds.Raw(), config.JSON.TimeoutSeconds.Valid(), config.TimeoutSeconds, &diags),
FillRatePerMinute: flattenFillRatePerMinute(config.JSON.FillRatePerMinute.Raw(), config.JSON.FillRatePerMinute.Valid(), config.FillRatePerMinute, &diags),
RebuildIdle: rebuildIdle,
}

if responseFieldPresent(config.JSON.ChromePolicy.Raw()) {
Expand Down
14 changes: 14 additions & 0 deletions internal/resources/browserpool/flatten_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func TestFlattenBrowserPoolMapsDurableState(t *testing.T) {
"headless": true,
"kiosk_mode": true,
"stealth": false,
"refresh_on_profile_update": true,
"start_url": "https://start.example",
"timeout_seconds": 90,
"fill_rate_per_minute": 20
Expand Down Expand Up @@ -74,6 +75,9 @@ func TestFlattenBrowserPoolMapsDurableState(t *testing.T) {
if got.Stealth.ValueBool() {
t.Fatal("stealth = true, want false")
}
if !got.RefreshOnProfileUpdate.ValueBool() {
t.Fatal("refresh_on_profile_update = false, want true")
}
if got.StartURL.ValueString() != "https://start.example" {
t.Fatalf("start_url = %q, want https://start.example", got.StartURL.ValueString())
}
Expand Down Expand Up @@ -178,6 +182,9 @@ func TestFlattenBrowserPoolNullsOmittedOptionalFields(t *testing.T) {
if !got.Stealth.IsNull() {
t.Fatalf("stealth = %#v, want null", got.Stealth)
}
if !got.RefreshOnProfileUpdate.IsNull() {
t.Fatalf("refresh_on_profile_update = %#v, want null", got.RefreshOnProfileUpdate)
}
assertStringNull(t, "start_url", got.StartURL)
if !got.TimeoutSeconds.IsNull() {
t.Fatalf("timeout_seconds = %#v, want null", got.TimeoutSeconds)
Expand Down Expand Up @@ -419,6 +426,13 @@ func TestFlattenBrowserPoolRejectsInvalidScalarResponseFields(t *testing.T) {
"headless": "true"
}
}`,
"refresh on profile update bool": `{
"id": "pool-1",
"browser_pool_config": {
"size": 1,
"refresh_on_profile_update": "true"
}
}`,
"empty string": `{
"id": "pool-1",
"browser_pool_config": {
Expand Down
Loading