From e30d5be2a62f4951bbfdcf5d1048e8e3d2457178 Mon Sep 17 00:00:00 2001
From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com>
Date: Sat, 11 Jul 2026 06:15:35 -0400
Subject: [PATCH 01/18] Add browser pool core data source
Expose exact browser pool lookup by ID or name with predictable project scoping. Keep the first slice limited to canonical identity, name, and durable size so runtime state cannot enter Terraform.
---
docs/data-sources/browser_pool.md | 26 ++
.../datasources/browserpool/datasource.go | 216 ++++++++++++++
.../browserpool/datasource_test.go | 276 ++++++++++++++++++
internal/provider/provider.go | 2 +
internal/provider/provider_test.go | 6 +-
5 files changed, 523 insertions(+), 3 deletions(-)
create mode 100644 docs/data-sources/browser_pool.md
create mode 100644 internal/datasources/browserpool/datasource.go
create mode 100644 internal/datasources/browserpool/datasource_test.go
diff --git a/docs/data-sources/browser_pool.md b/docs/data-sources/browser_pool.md
new file mode 100644
index 0000000..9b92a3c
--- /dev/null
+++ b/docs/data-sources/browser_pool.md
@@ -0,0 +1,26 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "kernel_browser_pool Data Source - Kernel"
+subcategory: ""
+description: |-
+ Lookup durable Kernel browser pool configuration.
+---
+
+# kernel_browser_pool (Data Source)
+
+Lookup durable Kernel browser pool configuration.
+
+
+
+
+## Schema
+
+### Optional
+
+- `id` (String) Browser pool ID.
+- `name` (String) Browser pool name for exact lookup.
+- `project_id` (String) Project to look the browser pool up in. Defaults to the provider `project_id`; when neither is set, the API key's project binding determines the project.
+
+### Read-Only
+
+- `size` (Number) Number of browsers maintained in the pool.
diff --git a/internal/datasources/browserpool/datasource.go b/internal/datasources/browserpool/datasource.go
new file mode 100644
index 0000000..fa044d0
--- /dev/null
+++ b/internal/datasources/browserpool/datasource.go
@@ -0,0 +1,216 @@
+package browserpool
+
+import (
+ "context"
+ "encoding/json"
+ "strconv"
+
+ "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
+ "github.com/hashicorp/terraform-plugin-framework/datasource"
+ dschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ kernel "github.com/kernel/kernel-go-sdk"
+ "github.com/kernel/terraform-provider-kernel/internal/datasources"
+ "github.com/kernel/terraform-provider-kernel/internal/projectscope"
+)
+
+var (
+ _ datasource.DataSource = (*browserPoolDataSource)(nil)
+ _ datasource.DataSourceWithConfigure = (*browserPoolDataSource)(nil)
+)
+
+type browserPoolClient interface {
+ DefaultProjectID() string
+ GetBrowserPool(context.Context, string, string) (*kernel.BrowserPool, error)
+}
+
+type browserPoolDataSource struct {
+ client browserPoolClient
+}
+
+type browserPoolModel struct {
+ ID types.String `tfsdk:"id"`
+ Name types.String `tfsdk:"name"`
+ ProjectID types.String `tfsdk:"project_id"`
+ Size types.Int64 `tfsdk:"size"`
+}
+
+func NewDataSource() datasource.DataSource {
+ return &browserPoolDataSource{}
+}
+
+func newDataSourceWithClient(client browserPoolClient) *browserPoolDataSource {
+ return &browserPoolDataSource{client: client}
+}
+
+func (d *browserPoolDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_browser_pool"
+}
+
+func (d *browserPoolDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
+ resp.Schema = dschema.Schema{
+ MarkdownDescription: "Lookup durable Kernel browser pool configuration.",
+ Attributes: map[string]dschema.Attribute{
+ "id": dschema.StringAttribute{
+ Optional: true,
+ Computed: true,
+ MarkdownDescription: "Browser pool ID.",
+ },
+ "name": dschema.StringAttribute{
+ Optional: true,
+ Computed: true,
+ MarkdownDescription: "Browser pool name for exact lookup.",
+ },
+ "project_id": dschema.StringAttribute{
+ Optional: true,
+ MarkdownDescription: "Project to look the browser pool up in. Defaults to the provider `project_id`; when neither is set, the API key's project binding determines the project.",
+ Validators: []validator.String{
+ stringvalidator.LengthAtLeast(1),
+ },
+ },
+ "size": dschema.Int64Attribute{
+ Computed: true,
+ MarkdownDescription: "Number of browsers maintained in the pool.",
+ },
+ },
+ }
+}
+
+func (d *browserPoolDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
+ if req.ProviderData == nil {
+ return
+ }
+
+ client, ok := req.ProviderData.(browserPoolClient)
+ if !ok {
+ resp.Diagnostics.AddError(
+ "Unexpected Kernel Client Type",
+ "Expected provider data to implement the browser pool data source durable client contract.",
+ )
+ return
+ }
+ d.client = client
+}
+
+func (d *browserPoolDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
+ var config browserPoolModel
+ resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ state, diags := d.read(ctx, config)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
+}
+
+func (d *browserPoolDataSource) read(ctx context.Context, config browserPoolModel) (browserPoolModel, diag.Diagnostics) {
+ var diags diag.Diagnostics
+ if d.client == nil {
+ diags.AddError("Missing Kernel Client", "The browser pool data source was not configured with a Kernel client.")
+ return browserPoolModel{}, diags
+ }
+
+ selector, selectorDiags := datasources.ResolveIDNameSelector("Browser Pool", "kernel_browser_pool", config.ID, config.Name)
+ diags.Append(selectorDiags...)
+ if diags.HasError() {
+ return browserPoolModel{}, diags
+ }
+
+ projectID := projectscope.ResolveDataSource(&diags, config.ProjectID, d.client.DefaultProjectID())
+ if diags.HasError() {
+ return browserPoolModel{}, diags
+ }
+
+ var idOrName string
+ switch {
+ case selector.HasID:
+ idOrName = config.ID.ValueString()
+ case selector.HasName:
+ idOrName = config.Name.ValueString()
+ default:
+ diags.AddError("Missing Browser Pool Selector", "Configure id or name for kernel_browser_pool.")
+ return browserPoolModel{}, diags
+ }
+
+ pool, err := d.client.GetBrowserPool(ctx, projectID, idOrName)
+ if err != nil {
+ projectscope.AddError(&diags, "Read Kernel Browser Pool", projectID, err)
+ return browserPoolModel{}, diags
+ }
+ if pool == nil {
+ diags.AddError("Read Kernel Browser Pool", "Kernel returned an empty browser pool response.")
+ return browserPoolModel{}, diags
+ }
+
+ state, flattenDiags := flattenBrowserPool(*pool)
+ diags.Append(flattenDiags...)
+ if diags.HasError() {
+ return browserPoolModel{}, diags
+ }
+ if selector.HasID && state.ID.ValueString() != config.ID.ValueString() {
+ diags.AddError(
+ "Browser Pool ID Mismatch",
+ "Kernel returned browser pool "+strconv.Quote(state.ID.ValueString())+" for id selector "+strconv.Quote(config.ID.ValueString())+".",
+ )
+ return browserPoolModel{}, diags
+ }
+ if selector.HasName && (state.Name.IsNull() || state.Name.ValueString() != config.Name.ValueString()) {
+ diags.AddError(
+ "Browser Pool Name Mismatch",
+ "Kernel returned a browser pool whose name does not match exact selector "+strconv.Quote(config.Name.ValueString())+".",
+ )
+ return browserPoolModel{}, diags
+ }
+
+ state.ProjectID = config.ProjectID
+ return state, diags
+}
+
+func flattenBrowserPool(pool kernel.BrowserPool) (browserPoolModel, diag.Diagnostics) {
+ var diags diag.Diagnostics
+ if !datasources.ValidResponseString(pool.JSON.ID.Raw(), pool.JSON.ID.Valid(), pool.ID) {
+ datasources.AddInvalidResponseField(&diags, "Browser Pool", "id")
+ }
+ if !validResponseInt64(pool.BrowserPoolConfig.JSON.Size.Raw(), pool.BrowserPoolConfig.JSON.Size.Valid(), pool.BrowserPoolConfig.Size) || pool.BrowserPoolConfig.Size < 1 {
+ datasources.AddInvalidResponseField(&diags, "Browser Pool", "browser_pool_config.size")
+ }
+
+ name := types.StringNull()
+ switch {
+ case datasources.FieldPresent(pool.JSON.Name.Raw()):
+ if !datasources.ValidResponseString(pool.JSON.Name.Raw(), pool.JSON.Name.Valid(), pool.Name) {
+ datasources.AddInvalidResponseField(&diags, "Browser Pool", "name")
+ } else {
+ name = types.StringValue(pool.Name)
+ }
+ case datasources.FieldPresent(pool.BrowserPoolConfig.JSON.Name.Raw()):
+ if !datasources.ValidResponseString(pool.BrowserPoolConfig.JSON.Name.Raw(), pool.BrowserPoolConfig.JSON.Name.Valid(), pool.BrowserPoolConfig.Name) {
+ datasources.AddInvalidResponseField(&diags, "Browser Pool", "browser_pool_config.name")
+ } else {
+ name = types.StringValue(pool.BrowserPoolConfig.Name)
+ }
+ }
+ if diags.HasError() {
+ return browserPoolModel{}, diags
+ }
+
+ return browserPoolModel{
+ ID: types.StringValue(pool.ID),
+ Name: name,
+ Size: types.Int64Value(pool.BrowserPoolConfig.Size),
+ }, diags
+}
+
+func validResponseInt64(raw string, valid bool, value int64) bool {
+ if !datasources.FieldPresent(raw) || !valid {
+ return false
+ }
+ var decoded int64
+ return json.Unmarshal([]byte(raw), &decoded) == nil && decoded == value
+}
diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go
new file mode 100644
index 0000000..9bde833
--- /dev/null
+++ b/internal/datasources/browserpool/datasource_test.go
@@ -0,0 +1,276 @@
+package browserpool
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strconv"
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-framework/datasource"
+ "github.com/hashicorp/terraform-plugin-framework/tfsdk"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-go/tftypes"
+ kernel "github.com/kernel/kernel-go-sdk"
+ "github.com/kernel/terraform-provider-kernel/internal/kernelclient"
+)
+
+var _ browserPoolClient = kernelclient.Clients{}
+
+type fakeBrowserPoolClient struct {
+ defaultProjectID string
+ get func(context.Context, string, string) (*kernel.BrowserPool, error)
+}
+
+func (f fakeBrowserPoolClient) DefaultProjectID() string {
+ return f.defaultProjectID
+}
+
+func (f fakeBrowserPoolClient) GetBrowserPool(ctx context.Context, projectID, idOrName string) (*kernel.BrowserPool, error) {
+ if f.get == nil {
+ return nil, errors.New("unexpected GetBrowserPool call")
+ }
+ return f.get(ctx, projectID, idOrName)
+}
+
+func TestDataSourceMetadataSchemaAndConfigure(t *testing.T) {
+ t.Parallel()
+
+ ds := NewDataSource()
+ var metadata datasource.MetadataResponse
+ ds.Metadata(context.Background(), datasource.MetadataRequest{ProviderTypeName: "kernel"}, &metadata)
+ if metadata.TypeName != "kernel_browser_pool" {
+ t.Fatalf("type name = %q, want kernel_browser_pool", metadata.TypeName)
+ }
+
+ var schema datasource.SchemaResponse
+ ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema)
+ for _, name := range []string{"id", "name", "project_id", "size"} {
+ if _, ok := schema.Schema.Attributes[name]; !ok {
+ t.Fatalf("schema missing %s", name)
+ }
+ }
+ for _, runtimeField := range []string{"acquired_count", "available_count", "standby", "sessions"} {
+ if _, ok := schema.Schema.Attributes[runtimeField]; ok {
+ t.Fatalf("schema includes runtime field %s", runtimeField)
+ }
+ }
+
+ configured := &browserPoolDataSource{}
+ var configure datasource.ConfigureResponse
+ configured.Configure(context.Background(), datasource.ConfigureRequest{ProviderData: kernelclient.Clients{}}, &configure)
+ if configure.Diagnostics.HasError() || configured.client == nil {
+ t.Fatalf("configure diagnostics/client = %v/%v", configure.Diagnostics, configured.client)
+ }
+
+ var invalid datasource.ConfigureResponse
+ configured.Configure(context.Background(), datasource.ConfigureRequest{ProviderData: "not a client"}, &invalid)
+ if len(invalid.Diagnostics) != 1 || invalid.Diagnostics[0].Summary() != "Unexpected Kernel Client Type" {
+ t.Fatalf("invalid configure diagnostics = %v", invalid.Diagnostics)
+ }
+}
+
+func TestReadBrowserPoolByIDOrName(t *testing.T) {
+ t.Parallel()
+
+ tests := map[string]struct {
+ config browserPoolModel
+ defaultProjectID string
+ wantProjectID string
+ wantSelector string
+ }{
+ "id": {
+ config: browserPoolModel{ID: types.StringValue("pool-1"), ProjectID: types.StringValue("project-explicit")},
+ defaultProjectID: "project-default",
+ wantProjectID: "project-explicit",
+ wantSelector: "pool-1",
+ },
+ "name uses provider default": {
+ config: browserPoolModel{Name: types.StringValue("Pool"), ProjectID: types.StringNull()},
+ defaultProjectID: "project-default",
+ wantProjectID: "project-default",
+ wantSelector: "Pool",
+ },
+ "unscoped": {
+ config: browserPoolModel{ID: types.StringValue("pool-1"), ProjectID: types.StringNull()},
+ wantSelector: "pool-1",
+ },
+ }
+
+ for name, test := range tests {
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+ var gotProjectID, gotSelector string
+ ds := newDataSourceWithClient(fakeBrowserPoolClient{
+ defaultProjectID: test.defaultProjectID,
+ get: func(ctx context.Context, projectID, idOrName string) (*kernel.BrowserPool, error) {
+ gotProjectID, gotSelector = projectID, idOrName
+ return browserPoolForTest("pool-1", "Pool", 2), nil
+ },
+ })
+
+ state, diags := ds.read(context.Background(), test.config)
+ if diags.HasError() {
+ t.Fatalf("unexpected diagnostics: %v", diags)
+ }
+ if gotSelector != test.wantSelector {
+ t.Fatalf("selector = %q, want %q", gotSelector, test.wantSelector)
+ }
+ if gotProjectID != test.wantProjectID {
+ t.Fatalf("project = %q, want %q", gotProjectID, test.wantProjectID)
+ }
+ if state.ID.ValueString() != "pool-1" || state.Name.ValueString() != "Pool" || state.Size.ValueInt64() != 2 {
+ t.Fatalf("state = %#v", state)
+ }
+ if !state.ProjectID.Equal(test.config.ProjectID) {
+ t.Fatalf("state project_id = %v, want %v", state.ProjectID, test.config.ProjectID)
+ }
+ })
+ }
+}
+
+func TestReadSetsTerraformState(t *testing.T) {
+ t.Parallel()
+
+ ds := newDataSourceWithClient(fakeBrowserPoolClient{
+ get: func(context.Context, string, string) (*kernel.BrowserPool, error) {
+ return browserPoolForTest("pool-1", "Pool", 2), nil
+ },
+ })
+ var schema datasource.SchemaResponse
+ ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema)
+ req := datasource.ReadRequest{Config: tfsdk.Config{
+ Schema: schema.Schema,
+ Raw: browserPoolConfigValue(
+ tftypes.NewValue(tftypes.String, nil),
+ tftypes.NewValue(tftypes.String, "Pool"),
+ tftypes.NewValue(tftypes.String, nil),
+ ),
+ }}
+ resp := datasource.ReadResponse{State: tfsdk.State{Schema: schema.Schema}}
+
+ ds.Read(context.Background(), req, &resp)
+ if resp.Diagnostics.HasError() {
+ t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics)
+ }
+
+ var state browserPoolModel
+ resp.Diagnostics.Append(resp.State.Get(context.Background(), &state)...)
+ if resp.Diagnostics.HasError() {
+ t.Fatalf("read state: %v", resp.Diagnostics)
+ }
+ if state.ID.ValueString() != "pool-1" || state.Name.ValueString() != "Pool" || state.Size.ValueInt64() != 2 {
+ t.Fatalf("state = %#v", state)
+ }
+}
+
+func TestReadBrowserPoolAllowsUnnamedPoolByID(t *testing.T) {
+ t.Parallel()
+
+ ds := newDataSourceWithClient(fakeBrowserPoolClient{
+ get: func(context.Context, string, string) (*kernel.BrowserPool, error) {
+ return browserPoolForTest("pool-1", "", 1), nil
+ },
+ })
+ state, diags := ds.read(context.Background(), browserPoolModel{ID: types.StringValue("pool-1")})
+ if diags.HasError() {
+ t.Fatalf("unexpected diagnostics: %v", diags)
+ }
+ if !state.Name.IsNull() {
+ t.Fatalf("name = %v, want null", state.Name)
+ }
+}
+
+func TestReadBrowserPoolRejectsInvalidInputsAndResponses(t *testing.T) {
+ t.Parallel()
+
+ tests := map[string]struct {
+ client browserPoolClient
+ config browserPoolModel
+ }{
+ "missing client": {config: browserPoolModel{ID: types.StringValue("pool-1")}},
+ "missing selector": {client: fakeBrowserPoolClient{}},
+ "conflicting selectors": {
+ client: fakeBrowserPoolClient{},
+ config: browserPoolModel{ID: types.StringValue("pool-1"), Name: types.StringValue("Pool")},
+ },
+ "unknown project": {
+ client: fakeBrowserPoolClient{},
+ config: browserPoolModel{ID: types.StringValue("pool-1"), ProjectID: types.StringUnknown()},
+ },
+ "API error": {
+ client: fakeBrowserPoolClient{get: func(context.Context, string, string) (*kernel.BrowserPool, error) {
+ return nil, errors.New("connection reset")
+ }},
+ config: browserPoolModel{ID: types.StringValue("pool-1")},
+ },
+ "empty response": {
+ client: fakeBrowserPoolClient{get: func(context.Context, string, string) (*kernel.BrowserPool, error) { return nil, nil }},
+ config: browserPoolModel{ID: types.StringValue("pool-1")},
+ },
+ "invalid response": {
+ client: fakeBrowserPoolClient{get: func(context.Context, string, string) (*kernel.BrowserPool, error) {
+ return browserPoolFromJSON(`{"id":"pool-1","browser_pool_config":{"size":"2"}}`), nil
+ }},
+ config: browserPoolModel{ID: types.StringValue("pool-1")},
+ },
+ "ID mismatch": {
+ client: fakeBrowserPoolClient{get: func(context.Context, string, string) (*kernel.BrowserPool, error) {
+ return browserPoolForTest("pool-other", "Pool", 1), nil
+ }},
+ config: browserPoolModel{ID: types.StringValue("pool-1")},
+ },
+ "name mismatch": {
+ client: fakeBrowserPoolClient{get: func(context.Context, string, string) (*kernel.BrowserPool, error) {
+ return browserPoolForTest("pool-1", "Other", 1), nil
+ }},
+ config: browserPoolModel{Name: types.StringValue("Pool")},
+ },
+ }
+
+ for name, test := range tests {
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+ ds := newDataSourceWithClient(test.client)
+ _, diags := ds.read(context.Background(), test.config)
+ if !diags.HasError() {
+ t.Fatal("expected diagnostics")
+ }
+ })
+ }
+}
+
+func browserPoolForTest(id, name string, size int64) *kernel.BrowserPool {
+ nameJSON := "null"
+ configName := ""
+ if name != "" {
+ nameJSON = strconv.Quote(name)
+ configName = `,"name":` + strconv.Quote(name)
+ }
+ return browserPoolFromJSON(`{"id":` + strconv.Quote(id) + `,"name":` + nameJSON + `,"browser_pool_config":{"size":` + strconv.FormatInt(size, 10) + configName + `}}`)
+}
+
+func browserPoolFromJSON(body string) *kernel.BrowserPool {
+ var pool kernel.BrowserPool
+ if err := json.Unmarshal([]byte(body), &pool); err != nil {
+ panic(err)
+ }
+ return &pool
+}
+
+func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value {
+ return tftypes.NewValue(
+ tftypes.Object{AttributeTypes: map[string]tftypes.Type{
+ "id": tftypes.String,
+ "name": tftypes.String,
+ "project_id": tftypes.String,
+ "size": tftypes.Number,
+ }},
+ map[string]tftypes.Value{
+ "id": id,
+ "name": name,
+ "project_id": projectID,
+ "size": tftypes.NewValue(tftypes.Number, nil),
+ },
+ )
+}
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index 5e3a9ee..2e647a3 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -7,6 +7,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/provider"
"github.com/hashicorp/terraform-plugin-framework/provider/schema"
"github.com/hashicorp/terraform-plugin-framework/resource"
+ browserpooldatasource "github.com/kernel/terraform-provider-kernel/internal/datasources/browserpool"
"github.com/kernel/terraform-provider-kernel/internal/datasources/extension"
"github.com/kernel/terraform-provider-kernel/internal/datasources/profile"
projectdatasource "github.com/kernel/terraform-provider-kernel/internal/datasources/project"
@@ -88,6 +89,7 @@ func (p *kernelProvider) Resources(ctx context.Context) []func() resource.Resour
func (p *kernelProvider) DataSources(ctx context.Context) []func() datasource.DataSource {
return []func() datasource.DataSource{
+ browserpooldatasource.NewDataSource,
projectdatasource.NewDataSource,
profile.NewDataSource,
proxy.NewDataSource,
diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go
index a3fdd0d..f1905f7 100644
--- a/internal/provider/provider_test.go
+++ b/internal/provider/provider_test.go
@@ -62,8 +62,8 @@ func TestProviderRegistersDataSources(t *testing.T) {
p := provider.New("test")()
dataSources := p.DataSources(context.Background())
- if len(dataSources) != 4 {
- t.Fatalf("DataSources length = %d, want 4", len(dataSources))
+ if len(dataSources) != 5 {
+ t.Fatalf("DataSources length = %d, want 5", len(dataSources))
}
got := make(map[string]bool, len(dataSources))
@@ -77,7 +77,7 @@ func TestProviderRegistersDataSources(t *testing.T) {
got[resp.TypeName] = true
}
- for _, want := range []string{"kernel_project", "kernel_profile", "kernel_proxy", "kernel_extension"} {
+ for _, want := range []string{"kernel_browser_pool", "kernel_project", "kernel_profile", "kernel_proxy", "kernel_extension"} {
if !got[want] {
t.Fatalf("missing data source %s; got %v", want, got)
}
From 01562e482f571b16f8731466d1ba605da423ab34 Mon Sep 17 00:00:00 2001
From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com>
Date: Mon, 3 Aug 2026 09:52:02 -0400
Subject: [PATCH 02/18] test browser pool data source schema mutations
Lock selector, project scope, and computed attribute semantics after targeted mutation testing exposed missing schema assertions.
---
.../browserpool/datasource_test.go | 46 +++++++++++++++++++
1 file changed, 46 insertions(+)
diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go
index 9bde833..f190292 100644
--- a/internal/datasources/browserpool/datasource_test.go
+++ b/internal/datasources/browserpool/datasource_test.go
@@ -8,6 +8,9 @@ import (
"testing"
"github.com/hashicorp/terraform-plugin-framework/datasource"
+ dschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-go/tftypes"
@@ -70,6 +73,49 @@ func TestDataSourceMetadataSchemaAndConfigure(t *testing.T) {
}
}
+func TestDataSourceSchemaSemantics(t *testing.T) {
+ t.Parallel()
+
+ ds := NewDataSource()
+ var resp datasource.SchemaResponse
+ ds.Schema(context.Background(), datasource.SchemaRequest{}, &resp)
+
+ id, ok := resp.Schema.Attributes["id"].(dschema.StringAttribute)
+ if !ok || !id.Optional || !id.Computed || id.Required {
+ t.Fatalf("id must be an optional, computed string: %#v", resp.Schema.Attributes["id"])
+ }
+ name, ok := resp.Schema.Attributes["name"].(dschema.StringAttribute)
+ if !ok || !name.Optional || !name.Computed || name.Required {
+ t.Fatalf("name must be an optional, computed string: %#v", resp.Schema.Attributes["name"])
+ }
+ projectID, ok := resp.Schema.Attributes["project_id"].(dschema.StringAttribute)
+ if !ok || !projectID.Optional || projectID.Computed || projectID.Required {
+ t.Fatalf("project_id must be an optional string: %#v", resp.Schema.Attributes["project_id"])
+ }
+ size, ok := resp.Schema.Attributes["size"].(dschema.Int64Attribute)
+ if !ok || !size.Computed || size.Optional || size.Required {
+ t.Fatalf("size must be a computed integer: %#v", resp.Schema.Attributes["size"])
+ }
+
+ if !validateProjectID(projectID.Validators, "").HasError() {
+ t.Fatal("project_id accepted an empty string")
+ }
+ if diags := validateProjectID(projectID.Validators, "project-1"); diags.HasError() {
+ t.Fatalf("project_id rejected a non-empty string: %v", diags)
+ }
+}
+
+func validateProjectID(validators []validator.String, value string) diag.Diagnostics {
+ var diags diag.Diagnostics
+ for _, candidate := range validators {
+ req := validator.StringRequest{ConfigValue: types.StringValue(value)}
+ var resp validator.StringResponse
+ candidate.ValidateString(context.Background(), req, &resp)
+ diags.Append(resp.Diagnostics...)
+ }
+ return diags
+}
+
func TestReadBrowserPoolByIDOrName(t *testing.T) {
t.Parallel()
From 1dbab8a93aa3cd8d798d53b52c4f06457afd7822 Mon Sep 17 00:00:00 2001
From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com>
Date: Mon, 3 Aug 2026 10:07:04 -0400
Subject: [PATCH 03/18] test reusable data source schema modes
Keep schema mutation assertions concise so later browser-pool data source slices can verify their computed-only fields without duplicating type-specific checks.
---
.../browserpool/datasource_test.go | 32 +++++++++----------
1 file changed, 16 insertions(+), 16 deletions(-)
diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go
index f190292..d276787 100644
--- a/internal/datasources/browserpool/datasource_test.go
+++ b/internal/datasources/browserpool/datasource_test.go
@@ -80,23 +80,12 @@ func TestDataSourceSchemaSemantics(t *testing.T) {
var resp datasource.SchemaResponse
ds.Schema(context.Background(), datasource.SchemaRequest{}, &resp)
- id, ok := resp.Schema.Attributes["id"].(dschema.StringAttribute)
- if !ok || !id.Optional || !id.Computed || id.Required {
- t.Fatalf("id must be an optional, computed string: %#v", resp.Schema.Attributes["id"])
- }
- name, ok := resp.Schema.Attributes["name"].(dschema.StringAttribute)
- if !ok || !name.Optional || !name.Computed || name.Required {
- t.Fatalf("name must be an optional, computed string: %#v", resp.Schema.Attributes["name"])
- }
- projectID, ok := resp.Schema.Attributes["project_id"].(dschema.StringAttribute)
- if !ok || !projectID.Optional || projectID.Computed || projectID.Required {
- t.Fatalf("project_id must be an optional string: %#v", resp.Schema.Attributes["project_id"])
- }
- size, ok := resp.Schema.Attributes["size"].(dschema.Int64Attribute)
- if !ok || !size.Computed || size.Optional || size.Required {
- t.Fatalf("size must be a computed integer: %#v", resp.Schema.Attributes["size"])
- }
+ assertAttributeMode(t, resp.Schema, "id", true, true)
+ assertAttributeMode(t, resp.Schema, "name", true, true)
+ assertAttributeMode(t, resp.Schema, "project_id", true, false)
+ assertAttributeMode(t, resp.Schema, "size", false, true)
+ projectID := resp.Schema.Attributes["project_id"].(dschema.StringAttribute)
if !validateProjectID(projectID.Validators, "").HasError() {
t.Fatal("project_id accepted an empty string")
}
@@ -105,6 +94,17 @@ func TestDataSourceSchemaSemantics(t *testing.T) {
}
}
+func assertAttributeMode(t *testing.T, schema dschema.Schema, name string, optional, computed bool) {
+ t.Helper()
+ attribute, ok := schema.Attributes[name]
+ if !ok {
+ t.Fatalf("schema missing %s", name)
+ }
+ if attribute.IsOptional() != optional || attribute.IsComputed() != computed || attribute.IsRequired() {
+ t.Fatalf("%s has unexpected schema mode: %#v", name, attribute)
+ }
+}
+
func validateProjectID(validators []validator.String, value string) diag.Diagnostics {
var diags diag.Diagnostics
for _, candidate := range validators {
From f10b8f780fd7e37fcfc3e79b0da97abd28bdf343 Mon Sep 17 00:00:00 2001
From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com>
Date: Sat, 11 Jul 2026 06:35:58 -0400
Subject: [PATCH 04/18] Expose browser pool resolved references
Read canonical profile and ordered extension IDs from the SDK response, with strict validation and ID-only fallback for legacy echoes. Keep runtime fields outside Terraform state.
---
docs/data-sources/browser_pool.md | 2 +
.../datasources/browserpool/datasource.go | 103 +++++++++++++-
.../browserpool/datasource_test.go | 131 ++++++++++++++++--
3 files changed, 219 insertions(+), 17 deletions(-)
diff --git a/docs/data-sources/browser_pool.md b/docs/data-sources/browser_pool.md
index 9b92a3c..1e7b1e9 100644
--- a/docs/data-sources/browser_pool.md
+++ b/docs/data-sources/browser_pool.md
@@ -23,4 +23,6 @@ Lookup durable Kernel browser pool configuration.
### Read-Only
+- `extension_ids` (List of String) Resolved extension IDs attached to the pool, in load order.
+- `profile_id` (String) Resolved profile ID attached to the pool, if any.
- `size` (Number) Number of browsers maintained in the pool.
diff --git a/internal/datasources/browserpool/datasource.go b/internal/datasources/browserpool/datasource.go
index fa044d0..aa9fa0b 100644
--- a/internal/datasources/browserpool/datasource.go
+++ b/internal/datasources/browserpool/datasource.go
@@ -3,15 +3,18 @@ package browserpool
import (
"context"
"encoding/json"
+ "fmt"
"strconv"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
+ "github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/datasource"
dschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
kernel "github.com/kernel/kernel-go-sdk"
+ "github.com/kernel/kernel-go-sdk/shared"
"github.com/kernel/terraform-provider-kernel/internal/datasources"
"github.com/kernel/terraform-provider-kernel/internal/projectscope"
)
@@ -31,10 +34,12 @@ type browserPoolDataSource struct {
}
type browserPoolModel struct {
- ID types.String `tfsdk:"id"`
- Name types.String `tfsdk:"name"`
- ProjectID types.String `tfsdk:"project_id"`
- Size types.Int64 `tfsdk:"size"`
+ ID types.String `tfsdk:"id"`
+ Name types.String `tfsdk:"name"`
+ ProjectID types.String `tfsdk:"project_id"`
+ Size types.Int64 `tfsdk:"size"`
+ ProfileID types.String `tfsdk:"profile_id"`
+ ExtensionIDs types.List `tfsdk:"extension_ids"`
}
func NewDataSource() datasource.DataSource {
@@ -74,6 +79,15 @@ func (d *browserPoolDataSource) Schema(_ context.Context, _ datasource.SchemaReq
Computed: true,
MarkdownDescription: "Number of browsers maintained in the pool.",
},
+ "profile_id": dschema.StringAttribute{
+ Computed: true,
+ MarkdownDescription: "Resolved profile ID attached to the pool, if any.",
+ },
+ "extension_ids": dschema.ListAttribute{
+ Computed: true,
+ ElementType: types.StringType,
+ MarkdownDescription: "Resolved extension IDs attached to the pool, in load order.",
+ },
},
}
}
@@ -201,12 +215,87 @@ func flattenBrowserPool(pool kernel.BrowserPool) (browserPoolModel, diag.Diagnos
}
return browserPoolModel{
- ID: types.StringValue(pool.ID),
- Name: name,
- Size: types.Int64Value(pool.BrowserPoolConfig.Size),
+ ID: types.StringValue(pool.ID),
+ Name: name,
+ Size: types.Int64Value(pool.BrowserPoolConfig.Size),
+ ProfileID: flattenResolvedProfileID(pool, &diags),
+ ExtensionIDs: flattenResolvedExtensionIDs(pool, &diags),
}, diags
}
+func flattenResolvedProfileID(pool kernel.BrowserPool, diags *diag.Diagnostics) types.String {
+ raw := pool.JSON.ProfileID.Raw()
+ if raw != "" {
+ if !datasources.FieldPresent(raw) {
+ datasources.AddInvalidResponseField(diags, "Browser Pool", "profile_id")
+ return types.StringNull()
+ }
+ if !datasources.ValidResponseString(raw, pool.JSON.ProfileID.Valid(), pool.ProfileID) {
+ datasources.AddInvalidResponseField(diags, "Browser Pool", "profile_id")
+ return types.StringNull()
+ }
+ return types.StringValue(pool.ProfileID)
+ }
+
+ profile := pool.BrowserPoolConfig.Profile
+ if !datasources.FieldPresent(pool.BrowserPoolConfig.JSON.Profile.Raw()) {
+ return types.StringNull()
+ }
+ if !datasources.ValidResponseString(profile.JSON.ID.Raw(), profile.JSON.ID.Valid(), profile.ID) {
+ datasources.AddInvalidResponseField(diags, "Browser Pool", "browser_pool_config.profile.id")
+ return types.StringNull()
+ }
+ return types.StringValue(profile.ID)
+}
+
+func flattenResolvedExtensionIDs(pool kernel.BrowserPool, diags *diag.Diagnostics) types.List {
+ raw := pool.JSON.ExtensionIDs.Raw()
+ if raw != "" {
+ return flattenStringList("extension_ids", raw, pool.JSON.ExtensionIDs.Valid(), pool.ExtensionIDs, diags)
+ }
+
+ config := pool.BrowserPoolConfig
+ if !datasources.FieldPresent(config.JSON.Extensions.Raw()) {
+ return types.ListValueMust(types.StringType, nil)
+ }
+ return flattenExtensionIDs(config.JSON.Extensions.Valid(), config.Extensions, diags)
+}
+
+func flattenStringList(field, raw string, valid bool, values []string, diags *diag.Diagnostics) types.List {
+ var decoded []string
+ if !datasources.FieldPresent(raw) || !valid || json.Unmarshal([]byte(raw), &decoded) != nil || len(decoded) != len(values) {
+ datasources.AddInvalidResponseField(diags, "Browser Pool", field)
+ return types.ListNull(types.StringType)
+ }
+
+ elements := make([]attr.Value, 0, len(values))
+ for index, value := range values {
+ if value == "" || decoded[index] != value {
+ datasources.AddInvalidResponseField(diags, "Browser Pool", fmt.Sprintf("%s[%d]", field, index))
+ return types.ListNull(types.StringType)
+ }
+ elements = append(elements, types.StringValue(value))
+ }
+ return types.ListValueMust(types.StringType, elements)
+}
+
+func flattenExtensionIDs(valid bool, extensions []shared.BrowserExtension, diags *diag.Diagnostics) types.List {
+ if !valid {
+ datasources.AddInvalidResponseField(diags, "Browser Pool", "browser_pool_config.extensions")
+ return types.ListNull(types.StringType)
+ }
+
+ elements := make([]attr.Value, 0, len(extensions))
+ for index, extension := range extensions {
+ if !datasources.ValidResponseString(extension.JSON.ID.Raw(), extension.JSON.ID.Valid(), extension.ID) {
+ datasources.AddInvalidResponseField(diags, "Browser Pool", fmt.Sprintf("browser_pool_config.extensions[%d].id", index))
+ return types.ListNull(types.StringType)
+ }
+ elements = append(elements, types.StringValue(extension.ID))
+ }
+ return types.ListValueMust(types.StringType, elements)
+}
+
func validResponseInt64(raw string, valid bool, value int64) bool {
if !datasources.FieldPresent(raw) || !valid {
return false
diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go
index d276787..aa47b4b 100644
--- a/internal/datasources/browserpool/datasource_test.go
+++ b/internal/datasources/browserpool/datasource_test.go
@@ -48,7 +48,7 @@ func TestDataSourceMetadataSchemaAndConfigure(t *testing.T) {
var schema datasource.SchemaResponse
ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema)
- for _, name := range []string{"id", "name", "project_id", "size"} {
+ for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "extension_ids"} {
if _, ok := schema.Schema.Attributes[name]; !ok {
t.Fatalf("schema missing %s", name)
}
@@ -208,6 +208,96 @@ func TestReadSetsTerraformState(t *testing.T) {
if state.ID.ValueString() != "pool-1" || state.Name.ValueString() != "Pool" || state.Size.ValueInt64() != 2 {
t.Fatalf("state = %#v", state)
}
+ if !state.ProfileID.IsNull() {
+ t.Fatalf("profile_id = %v, want null", state.ProfileID)
+ }
+ assertBrowserPoolStringList(t, state.ExtensionIDs, nil)
+}
+
+func TestFlattenBrowserPoolResolvedReferences(t *testing.T) {
+ t.Parallel()
+
+ tests := map[string]struct {
+ body string
+ wantProfileID string
+ wantProfileNull bool
+ wantExtensionIDs []string
+ }{
+ "authoritative fields": {
+ body: `{
+ "id":"pool-1",
+ "profile_id":"profile-resolved",
+ "extension_ids":["extension-b","extension-a"],
+ "browser_pool_config":{
+ "size":1,
+ "profile":{"name":"profile-selector"},
+ "extensions":[{"name":"extension-selector-b"},{"name":"extension-selector-a"}]
+ }
+ }`,
+ wantProfileID: "profile-resolved",
+ wantExtensionIDs: []string{"extension-b", "extension-a"},
+ },
+ "legacy ID selectors": {
+ body: `{
+ "id":"pool-1",
+ "browser_pool_config":{
+ "size":1,
+ "profile":{"id":"profile-legacy"},
+ "extensions":[{"id":"extension-b"},{"id":"extension-a"}]
+ }
+ }`,
+ wantProfileID: "profile-legacy",
+ wantExtensionIDs: []string{"extension-b", "extension-a"},
+ },
+ "no references": {
+ body: `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1}}`,
+ wantProfileNull: true,
+ wantExtensionIDs: nil,
+ },
+ }
+
+ for name, test := range tests {
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+ state, diags := flattenBrowserPool(*browserPoolFromJSON(test.body))
+ if diags.HasError() {
+ t.Fatalf("unexpected diagnostics: %v", diags)
+ }
+ if test.wantProfileNull {
+ if !state.ProfileID.IsNull() {
+ t.Fatalf("profile_id = %v, want null", state.ProfileID)
+ }
+ } else if state.ProfileID.ValueString() != test.wantProfileID {
+ t.Fatalf("profile_id = %q, want %q", state.ProfileID.ValueString(), test.wantProfileID)
+ }
+ assertBrowserPoolStringList(t, state.ExtensionIDs, test.wantExtensionIDs)
+ })
+ }
+}
+
+func TestFlattenBrowserPoolRejectsInvalidResolvedReferences(t *testing.T) {
+ t.Parallel()
+
+ tests := map[string]string{
+ "null authoritative extensions": `{"id":"pool-1","extension_ids":null,"browser_pool_config":{"size":1}}`,
+ "non-list authoritative extensions": `{"id":"pool-1","extension_ids":{},"browser_pool_config":{"size":1}}`,
+ "empty authoritative extension ID": `{"id":"pool-1","extension_ids":[""],"browser_pool_config":{"size":1}}`,
+ "null authoritative profile ID": `{"id":"pool-1","profile_id":null,"extension_ids":[],"browser_pool_config":{"size":1}}`,
+ "non-string authoritative profile ID": `{"id":"pool-1","profile_id":1,"extension_ids":[],"browser_pool_config":{"size":1}}`,
+ "empty authoritative profile ID": `{"id":"pool-1","profile_id":"","extension_ids":[],"browser_pool_config":{"size":1}}`,
+ "legacy profile name only": `{"id":"pool-1","browser_pool_config":{"size":1,"profile":{"name":"profile-selector"}}}`,
+ "legacy extension name only": `{"id":"pool-1","browser_pool_config":{"size":1,"extensions":[{"name":"extension-selector"}]}}`,
+ }
+
+ for name, body := range tests {
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+ _, diags := flattenBrowserPool(*browserPoolFromJSON(body))
+ if !diags.HasError() {
+ t.Fatal("expected diagnostics")
+ }
+ })
+ }
}
func TestReadBrowserPoolAllowsUnnamedPoolByID(t *testing.T) {
@@ -293,7 +383,7 @@ func browserPoolForTest(id, name string, size int64) *kernel.BrowserPool {
nameJSON = strconv.Quote(name)
configName = `,"name":` + strconv.Quote(name)
}
- return browserPoolFromJSON(`{"id":` + strconv.Quote(id) + `,"name":` + nameJSON + `,"browser_pool_config":{"size":` + strconv.FormatInt(size, 10) + configName + `}}`)
+ return browserPoolFromJSON(`{"id":` + strconv.Quote(id) + `,"name":` + nameJSON + `,"extension_ids":[],"browser_pool_config":{"size":` + strconv.FormatInt(size, 10) + configName + `}}`)
}
func browserPoolFromJSON(body string) *kernel.BrowserPool {
@@ -307,16 +397,37 @@ func browserPoolFromJSON(body string) *kernel.BrowserPool {
func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value {
return tftypes.NewValue(
tftypes.Object{AttributeTypes: map[string]tftypes.Type{
- "id": tftypes.String,
- "name": tftypes.String,
- "project_id": tftypes.String,
- "size": tftypes.Number,
+ "id": tftypes.String,
+ "name": tftypes.String,
+ "project_id": tftypes.String,
+ "size": tftypes.Number,
+ "profile_id": tftypes.String,
+ "extension_ids": tftypes.List{ElementType: tftypes.String},
}},
map[string]tftypes.Value{
- "id": id,
- "name": name,
- "project_id": projectID,
- "size": tftypes.NewValue(tftypes.Number, nil),
+ "id": id,
+ "name": name,
+ "project_id": projectID,
+ "size": tftypes.NewValue(tftypes.Number, nil),
+ "profile_id": tftypes.NewValue(tftypes.String, nil),
+ "extension_ids": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, nil),
},
)
}
+
+func assertBrowserPoolStringList(t *testing.T, got types.List, want []string) {
+ t.Helper()
+ if got.IsNull() || got.IsUnknown() {
+ t.Fatalf("list = %v, want %v", got, want)
+ }
+ elements := got.Elements()
+ if len(elements) != len(want) {
+ t.Fatalf("list length = %d, want %d", len(elements), len(want))
+ }
+ for index, element := range elements {
+ value, ok := element.(types.String)
+ if !ok || value.ValueString() != want[index] {
+ t.Fatalf("list[%d] = %v, want %q", index, element, want[index])
+ }
+ }
+}
From 5721458a6f90b7b9cb3387b85d3966d6dc619ed4 Mon Sep 17 00:00:00 2001
From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com>
Date: Mon, 3 Aug 2026 10:10:08 -0400
Subject: [PATCH 05/18] test resolved reference schema mutations
Assert profile and extension references remain computed-only after semantic mutation testing showed schema-mode changes were not detected.
---
internal/datasources/browserpool/datasource_test.go | 2 ++
1 file changed, 2 insertions(+)
diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go
index aa47b4b..b2b82d8 100644
--- a/internal/datasources/browserpool/datasource_test.go
+++ b/internal/datasources/browserpool/datasource_test.go
@@ -84,6 +84,8 @@ func TestDataSourceSchemaSemantics(t *testing.T) {
assertAttributeMode(t, resp.Schema, "name", true, true)
assertAttributeMode(t, resp.Schema, "project_id", true, false)
assertAttributeMode(t, resp.Schema, "size", false, true)
+ assertAttributeMode(t, resp.Schema, "profile_id", false, true)
+ assertAttributeMode(t, resp.Schema, "extension_ids", false, true)
projectID := resp.Schema.Attributes["project_id"].(dschema.StringAttribute)
if !validateProjectID(projectID.Validators, "").HasError() {
From 8826bb11edf609dc78df0e107f0c6662411dfd5e Mon Sep 17 00:00:00 2001
From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com>
Date: Sat, 11 Jul 2026 07:03:06 -0400
Subject: [PATCH 06/18] Expose browser pool launch configuration
Read proxy and launch-mode fields into durable data-source state. Reject explicit null or malformed SDK values and preserve known false booleans through Terraform serialization.
---
docs/data-sources/browser_pool.md | 4 +
.../datasources/browserpool/datasource.go | 57 ++++++++++++-
.../browserpool/datasource_test.go | 83 ++++++++++++++++++-
3 files changed, 141 insertions(+), 3 deletions(-)
diff --git a/docs/data-sources/browser_pool.md b/docs/data-sources/browser_pool.md
index 1e7b1e9..9ff97af 100644
--- a/docs/data-sources/browser_pool.md
+++ b/docs/data-sources/browser_pool.md
@@ -24,5 +24,9 @@ Lookup durable Kernel browser pool configuration.
### Read-Only
- `extension_ids` (List of String) Resolved extension IDs attached to the pool, in load order.
+- `headless` (Boolean) Whether browsers use a headless image.
+- `kiosk_mode` (Boolean) Whether browsers launch in kiosk mode.
- `profile_id` (String) Resolved profile ID attached to the pool, if any.
+- `proxy_id` (String) Proxy ID attached to browsers in the pool, if any.
- `size` (Number) Number of browsers maintained in the pool.
+- `stealth` (Boolean) Whether browsers launch in stealth mode.
diff --git a/internal/datasources/browserpool/datasource.go b/internal/datasources/browserpool/datasource.go
index aa9fa0b..45267fb 100644
--- a/internal/datasources/browserpool/datasource.go
+++ b/internal/datasources/browserpool/datasource.go
@@ -40,6 +40,10 @@ type browserPoolModel struct {
Size types.Int64 `tfsdk:"size"`
ProfileID types.String `tfsdk:"profile_id"`
ExtensionIDs types.List `tfsdk:"extension_ids"`
+ ProxyID types.String `tfsdk:"proxy_id"`
+ Headless types.Bool `tfsdk:"headless"`
+ KioskMode types.Bool `tfsdk:"kiosk_mode"`
+ Stealth types.Bool `tfsdk:"stealth"`
}
func NewDataSource() datasource.DataSource {
@@ -88,6 +92,22 @@ func (d *browserPoolDataSource) Schema(_ context.Context, _ datasource.SchemaReq
ElementType: types.StringType,
MarkdownDescription: "Resolved extension IDs attached to the pool, in load order.",
},
+ "proxy_id": dschema.StringAttribute{
+ Computed: true,
+ MarkdownDescription: "Proxy ID attached to browsers in the pool, if any.",
+ },
+ "headless": dschema.BoolAttribute{
+ Computed: true,
+ MarkdownDescription: "Whether browsers use a headless image.",
+ },
+ "kiosk_mode": dschema.BoolAttribute{
+ Computed: true,
+ MarkdownDescription: "Whether browsers launch in kiosk mode.",
+ },
+ "stealth": dschema.BoolAttribute{
+ Computed: true,
+ MarkdownDescription: "Whether browsers launch in stealth mode.",
+ },
},
}
}
@@ -214,15 +234,42 @@ func flattenBrowserPool(pool kernel.BrowserPool) (browserPoolModel, diag.Diagnos
return browserPoolModel{}, diags
}
+ config := pool.BrowserPoolConfig
return browserPoolModel{
ID: types.StringValue(pool.ID),
Name: name,
- Size: types.Int64Value(pool.BrowserPoolConfig.Size),
+ Size: types.Int64Value(config.Size),
ProfileID: flattenResolvedProfileID(pool, &diags),
ExtensionIDs: flattenResolvedExtensionIDs(pool, &diags),
+ ProxyID: flattenOptionalString("browser_pool_config.proxy_id", config.JSON.ProxyID.Raw(), config.JSON.ProxyID.Valid(), config.ProxyID, &diags),
+ Headless: flattenOptionalBool("browser_pool_config.headless", config.JSON.Headless.Raw(), config.JSON.Headless.Valid(), config.Headless, &diags),
+ KioskMode: flattenOptionalBool("browser_pool_config.kiosk_mode", config.JSON.KioskMode.Raw(), config.JSON.KioskMode.Valid(), config.KioskMode, &diags),
+ Stealth: flattenOptionalBool("browser_pool_config.stealth", config.JSON.Stealth.Raw(), config.JSON.Stealth.Valid(), config.Stealth, &diags),
}, diags
}
+func flattenOptionalString(field, raw string, valid bool, value string, diags *diag.Diagnostics) types.String {
+ if raw == "" {
+ return types.StringNull()
+ }
+ if !datasources.ValidResponseString(raw, valid, value) {
+ datasources.AddInvalidResponseField(diags, "Browser Pool", field)
+ return types.StringNull()
+ }
+ return types.StringValue(value)
+}
+
+func flattenOptionalBool(field, raw string, valid bool, value bool, diags *diag.Diagnostics) types.Bool {
+ if raw == "" {
+ return types.BoolNull()
+ }
+ if !validResponseBool(raw, valid, value) {
+ datasources.AddInvalidResponseField(diags, "Browser Pool", field)
+ return types.BoolNull()
+ }
+ return types.BoolValue(value)
+}
+
func flattenResolvedProfileID(pool kernel.BrowserPool, diags *diag.Diagnostics) types.String {
raw := pool.JSON.ProfileID.Raw()
if raw != "" {
@@ -303,3 +350,11 @@ func validResponseInt64(raw string, valid bool, value int64) bool {
var decoded int64
return json.Unmarshal([]byte(raw), &decoded) == nil && decoded == value
}
+
+func validResponseBool(raw string, valid bool, value bool) bool {
+ if !datasources.FieldPresent(raw) || !valid {
+ return false
+ }
+ var decoded bool
+ return json.Unmarshal([]byte(raw), &decoded) == nil && decoded == value
+}
diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go
index b2b82d8..12efb12 100644
--- a/internal/datasources/browserpool/datasource_test.go
+++ b/internal/datasources/browserpool/datasource_test.go
@@ -48,7 +48,7 @@ func TestDataSourceMetadataSchemaAndConfigure(t *testing.T) {
var schema datasource.SchemaResponse
ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema)
- for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "extension_ids"} {
+ for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "extension_ids", "proxy_id", "headless", "kiosk_mode", "stealth"} {
if _, ok := schema.Schema.Attributes[name]; !ok {
t.Fatalf("schema missing %s", name)
}
@@ -182,7 +182,18 @@ func TestReadSetsTerraformState(t *testing.T) {
ds := newDataSourceWithClient(fakeBrowserPoolClient{
get: func(context.Context, string, string) (*kernel.BrowserPool, error) {
- return browserPoolForTest("pool-1", "Pool", 2), nil
+ return browserPoolFromJSON(`{
+ "id":"pool-1",
+ "name":"Pool",
+ "extension_ids":[],
+ "browser_pool_config":{
+ "size":2,
+ "proxy_id":"proxy-1",
+ "headless":true,
+ "kiosk_mode":false,
+ "stealth":true
+ }
+ }`), nil
},
})
var schema datasource.SchemaResponse
@@ -214,6 +225,66 @@ func TestReadSetsTerraformState(t *testing.T) {
t.Fatalf("profile_id = %v, want null", state.ProfileID)
}
assertBrowserPoolStringList(t, state.ExtensionIDs, nil)
+ if state.ProxyID.ValueString() != "proxy-1" || !state.Headless.ValueBool() || state.KioskMode.IsNull() || state.KioskMode.ValueBool() || !state.Stealth.ValueBool() {
+ t.Fatalf("launch state = %#v", state)
+ }
+}
+
+func TestFlattenBrowserPoolLaunchConfiguration(t *testing.T) {
+ t.Parallel()
+
+ state, diags := flattenBrowserPool(*browserPoolFromJSON(`{
+ "id":"pool-1",
+ "extension_ids":[],
+ "browser_pool_config":{
+ "size":1,
+ "proxy_id":"proxy-1",
+ "headless":true,
+ "kiosk_mode":false,
+ "stealth":true
+ }
+ }`))
+ if diags.HasError() {
+ t.Fatalf("unexpected diagnostics: %v", diags)
+ }
+ if state.ProxyID.ValueString() != "proxy-1" {
+ t.Fatalf("proxy_id = %q, want proxy-1", state.ProxyID.ValueString())
+ }
+ if !state.Headless.ValueBool() {
+ t.Fatal("headless = false, want true")
+ }
+ if state.KioskMode.IsNull() || state.KioskMode.ValueBool() {
+ t.Fatalf("kiosk_mode = %v, want known false", state.KioskMode)
+ }
+ if !state.Stealth.ValueBool() {
+ t.Fatal("stealth = false, want true")
+ }
+}
+
+func TestFlattenBrowserPoolRejectsInvalidLaunchConfiguration(t *testing.T) {
+ t.Parallel()
+
+ tests := map[string]string{
+ "empty proxy ID": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"proxy_id":""}}`,
+ "null proxy ID": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"proxy_id":null}}`,
+ "non-string proxy": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"proxy_id":1}}`,
+ "null headless": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"headless":null}}`,
+ "non-bool headless": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"headless":"true"}}`,
+ "null kiosk": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"kiosk_mode":null}}`,
+ "non-bool kiosk": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"kiosk_mode":1}}`,
+ "null stealth": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"stealth":null}}`,
+ "non-bool stealth": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"stealth":{}}}`,
+ }
+
+ for name, body := range tests {
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+ _, diags := flattenBrowserPool(*browserPoolFromJSON(body))
+ if !diags.HasError() {
+ t.Fatal("expected diagnostics")
+ }
+ })
+ }
}
func TestFlattenBrowserPoolResolvedReferences(t *testing.T) {
@@ -405,6 +476,10 @@ func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value {
"size": tftypes.Number,
"profile_id": tftypes.String,
"extension_ids": tftypes.List{ElementType: tftypes.String},
+ "proxy_id": tftypes.String,
+ "headless": tftypes.Bool,
+ "kiosk_mode": tftypes.Bool,
+ "stealth": tftypes.Bool,
}},
map[string]tftypes.Value{
"id": id,
@@ -413,6 +488,10 @@ func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value {
"size": tftypes.NewValue(tftypes.Number, nil),
"profile_id": tftypes.NewValue(tftypes.String, nil),
"extension_ids": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, nil),
+ "proxy_id": tftypes.NewValue(tftypes.String, nil),
+ "headless": tftypes.NewValue(tftypes.Bool, nil),
+ "kiosk_mode": tftypes.NewValue(tftypes.Bool, nil),
+ "stealth": tftypes.NewValue(tftypes.Bool, nil),
},
)
}
From 99001302a01f5aa9cd9a17b32836924a4d5cf765 Mon Sep 17 00:00:00 2001
From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com>
Date: Mon, 3 Aug 2026 10:15:44 -0400
Subject: [PATCH 07/18] test browser pool launch schema mutations
---
internal/datasources/browserpool/datasource_test.go | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go
index 12efb12..3165e98 100644
--- a/internal/datasources/browserpool/datasource_test.go
+++ b/internal/datasources/browserpool/datasource_test.go
@@ -86,6 +86,10 @@ func TestDataSourceSchemaSemantics(t *testing.T) {
assertAttributeMode(t, resp.Schema, "size", false, true)
assertAttributeMode(t, resp.Schema, "profile_id", false, true)
assertAttributeMode(t, resp.Schema, "extension_ids", false, true)
+ assertAttributeMode(t, resp.Schema, "proxy_id", false, true)
+ assertAttributeMode(t, resp.Schema, "headless", false, true)
+ assertAttributeMode(t, resp.Schema, "kiosk_mode", false, true)
+ assertAttributeMode(t, resp.Schema, "stealth", false, true)
projectID := resp.Schema.Attributes["project_id"].(dschema.StringAttribute)
if !validateProjectID(projectID.Validators, "").HasError() {
From a3bafba4dfc487b0f24430e1c8cf6ebe040b46d9 Mon Sep 17 00:00:00 2001
From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com>
Date: Sat, 11 Jul 2026 07:15:39 -0400
Subject: [PATCH 08/18] Expose browser pool warmup configuration
Read start URL, timeout, and fill rate into durable data-source state. Validate SDK response types and ranges while preserving omitted values and known zero.
---
docs/data-sources/browser_pool.md | 3 +
.../datasources/browserpool/datasource.go | 97 +++++++++++++----
.../browserpool/datasource_test.go | 103 ++++++++++++++----
3 files changed, 162 insertions(+), 41 deletions(-)
diff --git a/docs/data-sources/browser_pool.md b/docs/data-sources/browser_pool.md
index 9ff97af..5f05ade 100644
--- a/docs/data-sources/browser_pool.md
+++ b/docs/data-sources/browser_pool.md
@@ -24,9 +24,12 @@ Lookup durable Kernel browser pool configuration.
### Read-Only
- `extension_ids` (List of String) Resolved extension IDs attached to the pool, in load order.
+- `fill_rate_per_minute` (Number) Percentage of the pool filled per minute.
- `headless` (Boolean) Whether browsers use a headless image.
- `kiosk_mode` (Boolean) Whether browsers launch in kiosk mode.
- `profile_id` (String) Resolved profile ID attached to the pool, if any.
- `proxy_id` (String) Proxy ID attached to browsers in the pool, if any.
- `size` (Number) Number of browsers maintained in the pool.
+- `start_url` (String) URL opened when a browser is warmed into the pool, if configured.
- `stealth` (Boolean) Whether browsers launch in stealth mode.
+- `timeout_seconds` (Number) Default idle timeout in seconds for acquired browsers.
diff --git a/internal/datasources/browserpool/datasource.go b/internal/datasources/browserpool/datasource.go
index 45267fb..9a9ed8d 100644
--- a/internal/datasources/browserpool/datasource.go
+++ b/internal/datasources/browserpool/datasource.go
@@ -24,6 +24,12 @@ var (
_ datasource.DataSourceWithConfigure = (*browserPoolDataSource)(nil)
)
+const (
+ minBrowserPoolTimeoutSeconds = 10
+ maxBrowserPoolTimeoutSeconds = 259200
+ minBrowserPoolFillRate = 0
+)
+
type browserPoolClient interface {
DefaultProjectID() string
GetBrowserPool(context.Context, string, string) (*kernel.BrowserPool, error)
@@ -34,16 +40,19 @@ type browserPoolDataSource struct {
}
type browserPoolModel struct {
- ID types.String `tfsdk:"id"`
- Name types.String `tfsdk:"name"`
- ProjectID types.String `tfsdk:"project_id"`
- Size types.Int64 `tfsdk:"size"`
- ProfileID types.String `tfsdk:"profile_id"`
- ExtensionIDs types.List `tfsdk:"extension_ids"`
- ProxyID types.String `tfsdk:"proxy_id"`
- Headless types.Bool `tfsdk:"headless"`
- KioskMode types.Bool `tfsdk:"kiosk_mode"`
- Stealth types.Bool `tfsdk:"stealth"`
+ ID types.String `tfsdk:"id"`
+ Name types.String `tfsdk:"name"`
+ ProjectID types.String `tfsdk:"project_id"`
+ Size types.Int64 `tfsdk:"size"`
+ ProfileID types.String `tfsdk:"profile_id"`
+ ExtensionIDs types.List `tfsdk:"extension_ids"`
+ ProxyID types.String `tfsdk:"proxy_id"`
+ Headless types.Bool `tfsdk:"headless"`
+ KioskMode types.Bool `tfsdk:"kiosk_mode"`
+ Stealth types.Bool `tfsdk:"stealth"`
+ StartURL types.String `tfsdk:"start_url"`
+ TimeoutSeconds types.Int64 `tfsdk:"timeout_seconds"`
+ FillRatePerMinute types.Int64 `tfsdk:"fill_rate_per_minute"`
}
func NewDataSource() datasource.DataSource {
@@ -108,6 +117,18 @@ func (d *browserPoolDataSource) Schema(_ context.Context, _ datasource.SchemaReq
Computed: true,
MarkdownDescription: "Whether browsers launch in stealth mode.",
},
+ "start_url": dschema.StringAttribute{
+ Computed: true,
+ MarkdownDescription: "URL opened when a browser is warmed into the pool, if configured.",
+ },
+ "timeout_seconds": dschema.Int64Attribute{
+ Computed: true,
+ MarkdownDescription: "Default idle timeout in seconds for acquired browsers.",
+ },
+ "fill_rate_per_minute": dschema.Int64Attribute{
+ Computed: true,
+ MarkdownDescription: "Percentage of the pool filled per minute.",
+ },
},
}
}
@@ -236,15 +257,18 @@ func flattenBrowserPool(pool kernel.BrowserPool) (browserPoolModel, diag.Diagnos
config := pool.BrowserPoolConfig
return browserPoolModel{
- ID: types.StringValue(pool.ID),
- Name: name,
- Size: types.Int64Value(config.Size),
- ProfileID: flattenResolvedProfileID(pool, &diags),
- ExtensionIDs: flattenResolvedExtensionIDs(pool, &diags),
- ProxyID: flattenOptionalString("browser_pool_config.proxy_id", config.JSON.ProxyID.Raw(), config.JSON.ProxyID.Valid(), config.ProxyID, &diags),
- Headless: flattenOptionalBool("browser_pool_config.headless", config.JSON.Headless.Raw(), config.JSON.Headless.Valid(), config.Headless, &diags),
- KioskMode: flattenOptionalBool("browser_pool_config.kiosk_mode", config.JSON.KioskMode.Raw(), config.JSON.KioskMode.Valid(), config.KioskMode, &diags),
- Stealth: flattenOptionalBool("browser_pool_config.stealth", config.JSON.Stealth.Raw(), config.JSON.Stealth.Valid(), config.Stealth, &diags),
+ ID: types.StringValue(pool.ID),
+ Name: name,
+ Size: types.Int64Value(config.Size),
+ ProfileID: flattenResolvedProfileID(pool, &diags),
+ ExtensionIDs: flattenResolvedExtensionIDs(pool, &diags),
+ ProxyID: flattenOptionalString("browser_pool_config.proxy_id", config.JSON.ProxyID.Raw(), config.JSON.ProxyID.Valid(), config.ProxyID, &diags),
+ Headless: flattenOptionalBool("browser_pool_config.headless", config.JSON.Headless.Raw(), config.JSON.Headless.Valid(), config.Headless, &diags),
+ KioskMode: flattenOptionalBool("browser_pool_config.kiosk_mode", config.JSON.KioskMode.Raw(), config.JSON.KioskMode.Valid(), config.KioskMode, &diags),
+ Stealth: flattenOptionalBool("browser_pool_config.stealth", config.JSON.Stealth.Raw(), config.JSON.Stealth.Valid(), config.Stealth, &diags),
+ StartURL: flattenOptionalString("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),
}, diags
}
@@ -270,6 +294,41 @@ func flattenOptionalBool(field, raw string, valid bool, value bool, diags *diag.
return types.BoolValue(value)
}
+func flattenTimeoutSeconds(raw string, valid bool, value int64, diags *diag.Diagnostics) types.Int64 {
+ result := flattenOptionalInt64("browser_pool_config.timeout_seconds", raw, valid, value, diags)
+ if result.IsNull() {
+ return result
+ }
+ if value < minBrowserPoolTimeoutSeconds || value > maxBrowserPoolTimeoutSeconds {
+ datasources.AddInvalidResponseField(diags, "Browser Pool", "browser_pool_config.timeout_seconds")
+ return types.Int64Null()
+ }
+ return result
+}
+
+func flattenFillRatePerMinute(raw string, valid bool, value int64, diags *diag.Diagnostics) types.Int64 {
+ result := flattenOptionalInt64("browser_pool_config.fill_rate_per_minute", raw, valid, value, diags)
+ if result.IsNull() {
+ return result
+ }
+ if value < minBrowserPoolFillRate {
+ datasources.AddInvalidResponseField(diags, "Browser Pool", "browser_pool_config.fill_rate_per_minute")
+ return types.Int64Null()
+ }
+ return result
+}
+
+func flattenOptionalInt64(field, raw string, valid bool, value int64, diags *diag.Diagnostics) types.Int64 {
+ if raw == "" {
+ return types.Int64Null()
+ }
+ if !validResponseInt64(raw, valid, value) {
+ datasources.AddInvalidResponseField(diags, "Browser Pool", field)
+ return types.Int64Null()
+ }
+ return types.Int64Value(value)
+}
+
func flattenResolvedProfileID(pool kernel.BrowserPool, diags *diag.Diagnostics) types.String {
raw := pool.JSON.ProfileID.Raw()
if raw != "" {
diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go
index 3165e98..aee287c 100644
--- a/internal/datasources/browserpool/datasource_test.go
+++ b/internal/datasources/browserpool/datasource_test.go
@@ -48,7 +48,7 @@ func TestDataSourceMetadataSchemaAndConfigure(t *testing.T) {
var schema datasource.SchemaResponse
ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema)
- for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "extension_ids", "proxy_id", "headless", "kiosk_mode", "stealth"} {
+ for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "extension_ids", "proxy_id", "headless", "kiosk_mode", "stealth", "start_url", "timeout_seconds", "fill_rate_per_minute"} {
if _, ok := schema.Schema.Attributes[name]; !ok {
t.Fatalf("schema missing %s", name)
}
@@ -195,7 +195,10 @@ func TestReadSetsTerraformState(t *testing.T) {
"proxy_id":"proxy-1",
"headless":true,
"kiosk_mode":false,
- "stealth":true
+ "stealth":true,
+ "start_url":"chrome://newtab",
+ "timeout_seconds":10,
+ "fill_rate_per_minute":0
}
}`), nil
},
@@ -232,6 +235,56 @@ func TestReadSetsTerraformState(t *testing.T) {
if state.ProxyID.ValueString() != "proxy-1" || !state.Headless.ValueBool() || state.KioskMode.IsNull() || state.KioskMode.ValueBool() || !state.Stealth.ValueBool() {
t.Fatalf("launch state = %#v", state)
}
+ if state.StartURL.ValueString() != "chrome://newtab" || state.TimeoutSeconds.ValueInt64() != 10 || state.FillRatePerMinute.IsNull() || state.FillRatePerMinute.IsUnknown() || state.FillRatePerMinute.ValueInt64() != 0 {
+ t.Fatalf("warmup state = %#v", state)
+ }
+}
+
+func TestFlattenBrowserPoolWarmupConfigurationBoundaries(t *testing.T) {
+ t.Parallel()
+
+ omitted, diags := flattenBrowserPool(*browserPoolFromJSON(`{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1}}`))
+ if diags.HasError() {
+ t.Fatalf("unexpected omitted-field diagnostics: %v", diags)
+ }
+ if !omitted.StartURL.IsNull() || !omitted.TimeoutSeconds.IsNull() || !omitted.FillRatePerMinute.IsNull() {
+ t.Fatalf("omitted warmup state = %#v, want null values", omitted)
+ }
+
+ boundary, diags := flattenBrowserPool(*browserPoolFromJSON(`{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"timeout_seconds":259200,"fill_rate_per_minute":0}}`))
+ if diags.HasError() {
+ t.Fatalf("unexpected boundary diagnostics: %v", diags)
+ }
+ if boundary.TimeoutSeconds.ValueInt64() != 259200 || boundary.FillRatePerMinute.IsNull() || boundary.FillRatePerMinute.IsUnknown() || boundary.FillRatePerMinute.ValueInt64() != 0 {
+ t.Fatalf("boundary warmup state = %#v", boundary)
+ }
+}
+
+func TestFlattenBrowserPoolRejectsInvalidWarmupConfiguration(t *testing.T) {
+ t.Parallel()
+
+ tests := map[string]string{
+ "empty start URL": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"start_url":""}}`,
+ "null start URL": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"start_url":null}}`,
+ "non-string URL": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"start_url":1}}`,
+ "null timeout": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"timeout_seconds":null}}`,
+ "non-number timeout": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"timeout_seconds":"10"}}`,
+ "timeout too low": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"timeout_seconds":9}}`,
+ "timeout too high": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"timeout_seconds":259201}}`,
+ "null fill rate": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"fill_rate_per_minute":null}}`,
+ "non-number rate": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"fill_rate_per_minute":"0"}}`,
+ "negative fill rate": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"fill_rate_per_minute":-1}}`,
+ }
+
+ for name, body := range tests {
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+ _, diags := flattenBrowserPool(*browserPoolFromJSON(body))
+ if !diags.HasError() {
+ t.Fatal("expected diagnostics")
+ }
+ })
+ }
}
func TestFlattenBrowserPoolLaunchConfiguration(t *testing.T) {
@@ -474,28 +527,34 @@ func browserPoolFromJSON(body string) *kernel.BrowserPool {
func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value {
return tftypes.NewValue(
tftypes.Object{AttributeTypes: map[string]tftypes.Type{
- "id": tftypes.String,
- "name": tftypes.String,
- "project_id": tftypes.String,
- "size": tftypes.Number,
- "profile_id": tftypes.String,
- "extension_ids": tftypes.List{ElementType: tftypes.String},
- "proxy_id": tftypes.String,
- "headless": tftypes.Bool,
- "kiosk_mode": tftypes.Bool,
- "stealth": tftypes.Bool,
+ "id": tftypes.String,
+ "name": tftypes.String,
+ "project_id": tftypes.String,
+ "size": tftypes.Number,
+ "profile_id": tftypes.String,
+ "extension_ids": tftypes.List{ElementType: tftypes.String},
+ "proxy_id": tftypes.String,
+ "headless": tftypes.Bool,
+ "kiosk_mode": tftypes.Bool,
+ "stealth": tftypes.Bool,
+ "start_url": tftypes.String,
+ "timeout_seconds": tftypes.Number,
+ "fill_rate_per_minute": tftypes.Number,
}},
map[string]tftypes.Value{
- "id": id,
- "name": name,
- "project_id": projectID,
- "size": tftypes.NewValue(tftypes.Number, nil),
- "profile_id": tftypes.NewValue(tftypes.String, nil),
- "extension_ids": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, nil),
- "proxy_id": tftypes.NewValue(tftypes.String, nil),
- "headless": tftypes.NewValue(tftypes.Bool, nil),
- "kiosk_mode": tftypes.NewValue(tftypes.Bool, nil),
- "stealth": tftypes.NewValue(tftypes.Bool, nil),
+ "id": id,
+ "name": name,
+ "project_id": projectID,
+ "size": tftypes.NewValue(tftypes.Number, nil),
+ "profile_id": tftypes.NewValue(tftypes.String, nil),
+ "extension_ids": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, nil),
+ "proxy_id": tftypes.NewValue(tftypes.String, nil),
+ "headless": tftypes.NewValue(tftypes.Bool, nil),
+ "kiosk_mode": tftypes.NewValue(tftypes.Bool, nil),
+ "stealth": tftypes.NewValue(tftypes.Bool, nil),
+ "start_url": tftypes.NewValue(tftypes.String, nil),
+ "timeout_seconds": tftypes.NewValue(tftypes.Number, nil),
+ "fill_rate_per_minute": tftypes.NewValue(tftypes.Number, nil),
},
)
}
From 52a37cee7381dce63f5a863d086d725e214fe83b Mon Sep 17 00:00:00 2001
From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com>
Date: Mon, 3 Aug 2026 10:18:29 -0400
Subject: [PATCH 09/18] test browser pool warmup schema mutations
---
internal/datasources/browserpool/datasource_test.go | 3 +++
1 file changed, 3 insertions(+)
diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go
index aee287c..6cd7157 100644
--- a/internal/datasources/browserpool/datasource_test.go
+++ b/internal/datasources/browserpool/datasource_test.go
@@ -90,6 +90,9 @@ func TestDataSourceSchemaSemantics(t *testing.T) {
assertAttributeMode(t, resp.Schema, "headless", false, true)
assertAttributeMode(t, resp.Schema, "kiosk_mode", false, true)
assertAttributeMode(t, resp.Schema, "stealth", false, true)
+ assertAttributeMode(t, resp.Schema, "start_url", false, true)
+ assertAttributeMode(t, resp.Schema, "timeout_seconds", false, true)
+ assertAttributeMode(t, resp.Schema, "fill_rate_per_minute", false, true)
projectID := resp.Schema.Attributes["project_id"].(dschema.StringAttribute)
if !validateProjectID(projectID.Validators, "").HasError() {
From 6bab8cea32729432c6025842c29b272f932c7fdb Mon Sep 17 00:00:00 2001
From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com>
Date: Sat, 11 Jul 2026 07:27:36 -0400
Subject: [PATCH 10/18] Expose browser pool viewport
Read viewport configuration into a typed Terraform object. Reject malformed dimensions and preserve omitted viewport and refresh-rate values without partial state.
---
docs/data-sources/browser_pool.md | 10 +++
.../datasources/browserpool/datasource.go | 55 +++++++++++++++
.../browserpool/datasource_test.go | 68 ++++++++++++++++++-
3 files changed, 131 insertions(+), 2 deletions(-)
diff --git a/docs/data-sources/browser_pool.md b/docs/data-sources/browser_pool.md
index 5f05ade..7c6dccd 100644
--- a/docs/data-sources/browser_pool.md
+++ b/docs/data-sources/browser_pool.md
@@ -33,3 +33,13 @@ Lookup durable Kernel browser pool configuration.
- `start_url` (String) URL opened when a browser is warmed into the pool, if configured.
- `stealth` (Boolean) Whether browsers launch in stealth mode.
- `timeout_seconds` (Number) Default idle timeout in seconds for acquired browsers.
+- `viewport` (Attributes) Browser viewport configured for the pool, if any. (see [below for nested schema](#nestedatt--viewport))
+
+
+### Nested Schema for `viewport`
+
+Read-Only:
+
+- `height` (Number) Browser window height in pixels.
+- `refresh_rate` (Number) Display refresh rate in Hz, if configured.
+- `width` (Number) Browser window width in pixels.
diff --git a/internal/datasources/browserpool/datasource.go b/internal/datasources/browserpool/datasource.go
index 9a9ed8d..07fd0db 100644
--- a/internal/datasources/browserpool/datasource.go
+++ b/internal/datasources/browserpool/datasource.go
@@ -28,6 +28,7 @@ const (
minBrowserPoolTimeoutSeconds = 10
maxBrowserPoolTimeoutSeconds = 259200
minBrowserPoolFillRate = 0
+ minBrowserPoolViewportValue = 1
)
type browserPoolClient interface {
@@ -53,6 +54,7 @@ type browserPoolModel struct {
StartURL types.String `tfsdk:"start_url"`
TimeoutSeconds types.Int64 `tfsdk:"timeout_seconds"`
FillRatePerMinute types.Int64 `tfsdk:"fill_rate_per_minute"`
+ Viewport types.Object `tfsdk:"viewport"`
}
func NewDataSource() datasource.DataSource {
@@ -129,6 +131,15 @@ func (d *browserPoolDataSource) Schema(_ context.Context, _ datasource.SchemaReq
Computed: true,
MarkdownDescription: "Percentage of the pool filled per minute.",
},
+ "viewport": dschema.SingleNestedAttribute{
+ Computed: true,
+ MarkdownDescription: "Browser viewport configured for the pool, if any.",
+ Attributes: map[string]dschema.Attribute{
+ "width": dschema.Int64Attribute{Computed: true, MarkdownDescription: "Browser window width in pixels."},
+ "height": dschema.Int64Attribute{Computed: true, MarkdownDescription: "Browser window height in pixels."},
+ "refresh_rate": dschema.Int64Attribute{Computed: true, MarkdownDescription: "Display refresh rate in Hz, if configured."},
+ },
+ },
},
}
}
@@ -269,6 +280,7 @@ func flattenBrowserPool(pool kernel.BrowserPool) (browserPoolModel, diag.Diagnos
StartURL: flattenOptionalString("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),
+ Viewport: flattenViewport(config.JSON.Viewport.Raw(), config.JSON.Viewport.Valid(), config.Viewport, &diags),
}, diags
}
@@ -329,6 +341,49 @@ func flattenOptionalInt64(field, raw string, valid bool, value int64, diags *dia
return types.Int64Value(value)
}
+func flattenViewport(raw string, valid bool, viewport shared.BrowserViewport, diags *diag.Diagnostics) types.Object {
+ if raw == "" {
+ return types.ObjectNull(viewportAttributeTypes())
+ }
+ if !datasources.FieldPresent(raw) || !valid {
+ datasources.AddInvalidResponseField(diags, "Browser Pool", "browser_pool_config.viewport")
+ return types.ObjectNull(viewportAttributeTypes())
+ }
+
+ diagnosticsBefore := len(*diags)
+ width := flattenRequiredPositiveInt64("browser_pool_config.viewport.width", viewport.JSON.Width.Raw(), viewport.JSON.Width.Valid(), viewport.Width, diags)
+ height := flattenRequiredPositiveInt64("browser_pool_config.viewport.height", viewport.JSON.Height.Raw(), viewport.JSON.Height.Valid(), viewport.Height, diags)
+ refreshRate := types.Int64Null()
+ if viewport.JSON.RefreshRate.Raw() != "" {
+ refreshRate = flattenRequiredPositiveInt64("browser_pool_config.viewport.refresh_rate", viewport.JSON.RefreshRate.Raw(), viewport.JSON.RefreshRate.Valid(), viewport.RefreshRate, diags)
+ }
+ if len(*diags) > diagnosticsBefore {
+ return types.ObjectNull(viewportAttributeTypes())
+ }
+
+ return types.ObjectValueMust(viewportAttributeTypes(), map[string]attr.Value{
+ "width": width,
+ "height": height,
+ "refresh_rate": refreshRate,
+ })
+}
+
+func flattenRequiredPositiveInt64(field, raw string, valid bool, value int64, diags *diag.Diagnostics) types.Int64 {
+ if !validResponseInt64(raw, valid, value) || value < minBrowserPoolViewportValue {
+ datasources.AddInvalidResponseField(diags, "Browser Pool", field)
+ return types.Int64Null()
+ }
+ return types.Int64Value(value)
+}
+
+func viewportAttributeTypes() map[string]attr.Type {
+ return map[string]attr.Type{
+ "width": types.Int64Type,
+ "height": types.Int64Type,
+ "refresh_rate": types.Int64Type,
+ }
+}
+
func flattenResolvedProfileID(pool kernel.BrowserPool, diags *diag.Diagnostics) types.String {
raw := pool.JSON.ProfileID.Raw()
if raw != "" {
diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go
index 6cd7157..4d8c3e8 100644
--- a/internal/datasources/browserpool/datasource_test.go
+++ b/internal/datasources/browserpool/datasource_test.go
@@ -48,7 +48,7 @@ func TestDataSourceMetadataSchemaAndConfigure(t *testing.T) {
var schema datasource.SchemaResponse
ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema)
- for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "extension_ids", "proxy_id", "headless", "kiosk_mode", "stealth", "start_url", "timeout_seconds", "fill_rate_per_minute"} {
+ for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "extension_ids", "proxy_id", "headless", "kiosk_mode", "stealth", "start_url", "timeout_seconds", "fill_rate_per_minute", "viewport"} {
if _, ok := schema.Schema.Attributes[name]; !ok {
t.Fatalf("schema missing %s", name)
}
@@ -201,7 +201,8 @@ func TestReadSetsTerraformState(t *testing.T) {
"stealth":true,
"start_url":"chrome://newtab",
"timeout_seconds":10,
- "fill_rate_per_minute":0
+ "fill_rate_per_minute":0,
+ "viewport":{"width":1280,"height":800,"refresh_rate":60}
}
}`), nil
},
@@ -241,6 +242,51 @@ func TestReadSetsTerraformState(t *testing.T) {
if state.StartURL.ValueString() != "chrome://newtab" || state.TimeoutSeconds.ValueInt64() != 10 || state.FillRatePerMinute.IsNull() || state.FillRatePerMinute.IsUnknown() || state.FillRatePerMinute.ValueInt64() != 0 {
t.Fatalf("warmup state = %#v", state)
}
+ assertBrowserPoolViewport(t, state.Viewport, 1280, 800, types.Int64Value(60))
+}
+
+func TestFlattenBrowserPoolViewportOptionalFields(t *testing.T) {
+ t.Parallel()
+
+ omitted, diags := flattenBrowserPool(*browserPoolFromJSON(`{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1}}`))
+ if diags.HasError() {
+ t.Fatalf("unexpected omitted viewport diagnostics: %v", diags)
+ }
+ if !omitted.Viewport.IsNull() || len(omitted.Viewport.AttributeTypes(t.Context())) != 3 {
+ t.Fatalf("omitted viewport = %#v, want typed null", omitted.Viewport)
+ }
+
+ withoutRefreshRate, diags := flattenBrowserPool(*browserPoolFromJSON(`{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":1280,"height":800}}}`))
+ if diags.HasError() {
+ t.Fatalf("unexpected viewport diagnostics: %v", diags)
+ }
+ assertBrowserPoolViewport(t, withoutRefreshRate.Viewport, 1280, 800, types.Int64Null())
+}
+
+func TestFlattenBrowserPoolRejectsInvalidViewport(t *testing.T) {
+ t.Parallel()
+
+ tests := map[string]string{
+ "null viewport": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":null}}`,
+ "non-object viewport": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":[]}}`,
+ "missing width": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"height":800}}}`,
+ "missing height": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":1280}}}`,
+ "zero width": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":0,"height":800}}}`,
+ "negative height": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":1280,"height":-1}}}`,
+ "null refresh rate": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":1280,"height":800,"refresh_rate":null}}}`,
+ "zero refresh rate": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":1280,"height":800,"refresh_rate":0}}}`,
+ "non-number dimension": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":"1280","height":800}}}`,
+ }
+
+ for name, body := range tests {
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+ _, diags := flattenBrowserPool(*browserPoolFromJSON(body))
+ if !diags.HasError() {
+ t.Fatal("expected diagnostics")
+ }
+ })
+ }
}
func TestFlattenBrowserPoolWarmupConfigurationBoundaries(t *testing.T) {
@@ -528,6 +574,11 @@ func browserPoolFromJSON(body string) *kernel.BrowserPool {
}
func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value {
+ viewportType := tftypes.Object{AttributeTypes: map[string]tftypes.Type{
+ "width": tftypes.Number,
+ "height": tftypes.Number,
+ "refresh_rate": tftypes.Number,
+ }}
return tftypes.NewValue(
tftypes.Object{AttributeTypes: map[string]tftypes.Type{
"id": tftypes.String,
@@ -543,6 +594,7 @@ func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value {
"start_url": tftypes.String,
"timeout_seconds": tftypes.Number,
"fill_rate_per_minute": tftypes.Number,
+ "viewport": viewportType,
}},
map[string]tftypes.Value{
"id": id,
@@ -558,10 +610,22 @@ func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value {
"start_url": tftypes.NewValue(tftypes.String, nil),
"timeout_seconds": tftypes.NewValue(tftypes.Number, nil),
"fill_rate_per_minute": tftypes.NewValue(tftypes.Number, nil),
+ "viewport": tftypes.NewValue(viewportType, nil),
},
)
}
+func assertBrowserPoolViewport(t *testing.T, viewport types.Object, width, height int64, refreshRate types.Int64) {
+ t.Helper()
+ if viewport.IsNull() || viewport.IsUnknown() {
+ t.Fatalf("viewport = %#v, want known object", viewport)
+ }
+ attributes := viewport.Attributes()
+ if !attributes["width"].(types.Int64).Equal(types.Int64Value(width)) || !attributes["height"].(types.Int64).Equal(types.Int64Value(height)) || !attributes["refresh_rate"].(types.Int64).Equal(refreshRate) {
+ t.Fatalf("viewport = %#v, want %dx%d refresh %v", viewport, width, height, refreshRate)
+ }
+}
+
func assertBrowserPoolStringList(t *testing.T, got types.List, want []string) {
t.Helper()
if got.IsNull() || got.IsUnknown() {
From c851e417b7b6ce09b419f34b8f7aad2ee1af3759 Mon Sep 17 00:00:00 2001
From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com>
Date: Mon, 3 Aug 2026 10:21:50 -0400
Subject: [PATCH 11/18] test viewport schema and minimum boundary
---
.../browserpool/datasource_test.go | 23 ++++++++++++++++++-
1 file changed, 22 insertions(+), 1 deletion(-)
diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go
index 4d8c3e8..bdd714f 100644
--- a/internal/datasources/browserpool/datasource_test.go
+++ b/internal/datasources/browserpool/datasource_test.go
@@ -93,6 +93,12 @@ func TestDataSourceSchemaSemantics(t *testing.T) {
assertAttributeMode(t, resp.Schema, "start_url", false, true)
assertAttributeMode(t, resp.Schema, "timeout_seconds", false, true)
assertAttributeMode(t, resp.Schema, "fill_rate_per_minute", false, true)
+ assertAttributeMode(t, resp.Schema, "viewport", false, true)
+
+ viewport := resp.Schema.Attributes["viewport"].(dschema.SingleNestedAttribute)
+ for _, name := range []string{"width", "height", "refresh_rate"} {
+ assertAttributeMapMode(t, viewport.Attributes, name, false, true)
+ }
projectID := resp.Schema.Attributes["project_id"].(dschema.StringAttribute)
if !validateProjectID(projectID.Validators, "").HasError() {
@@ -105,7 +111,12 @@ func TestDataSourceSchemaSemantics(t *testing.T) {
func assertAttributeMode(t *testing.T, schema dschema.Schema, name string, optional, computed bool) {
t.Helper()
- attribute, ok := schema.Attributes[name]
+ assertAttributeMapMode(t, schema.Attributes, name, optional, computed)
+}
+
+func assertAttributeMapMode(t *testing.T, attributes map[string]dschema.Attribute, name string, optional, computed bool) {
+ t.Helper()
+ attribute, ok := attributes[name]
if !ok {
t.Fatalf("schema missing %s", name)
}
@@ -263,6 +274,16 @@ func TestFlattenBrowserPoolViewportOptionalFields(t *testing.T) {
assertBrowserPoolViewport(t, withoutRefreshRate.Viewport, 1280, 800, types.Int64Null())
}
+func TestFlattenBrowserPoolAcceptsMinimumViewport(t *testing.T) {
+ t.Parallel()
+
+ state, diags := flattenBrowserPool(*browserPoolFromJSON(`{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":1,"height":1,"refresh_rate":1}}}`))
+ if diags.HasError() {
+ t.Fatalf("unexpected minimum viewport diagnostics: %v", diags)
+ }
+ assertBrowserPoolViewport(t, state.Viewport, 1, 1, types.Int64Value(1))
+}
+
func TestFlattenBrowserPoolRejectsInvalidViewport(t *testing.T) {
t.Parallel()
From 2200295c156b743ca660cd62e4fd4d1cb4065623 Mon Sep 17 00:00:00 2001
From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com>
Date: Sat, 11 Jul 2026 07:49:57 -0400
Subject: [PATCH 12/18] Expose browser pool Chrome policy
Normalize SDK Chrome-policy JSON into stable Terraform string state. Keep loose maps at the response boundary and preserve absent or null policy compatibility.
---
docs/data-sources/browser_pool.md | 1 +
.../datasources/browserpool/datasource.go | 33 ++++++++++++
.../browserpool/datasource_test.go | 52 ++++++++++++++++++-
3 files changed, 84 insertions(+), 2 deletions(-)
diff --git a/docs/data-sources/browser_pool.md b/docs/data-sources/browser_pool.md
index 7c6dccd..7b803bf 100644
--- a/docs/data-sources/browser_pool.md
+++ b/docs/data-sources/browser_pool.md
@@ -23,6 +23,7 @@ Lookup durable Kernel browser pool configuration.
### Read-Only
+- `chrome_policy` (String) Normalized JSON object of Chrome enterprise policy overrides, if configured.
- `extension_ids` (List of String) Resolved extension IDs attached to the pool, in load order.
- `fill_rate_per_minute` (Number) Percentage of the pool filled per minute.
- `headless` (Boolean) Whether browsers use a headless image.
diff --git a/internal/datasources/browserpool/datasource.go b/internal/datasources/browserpool/datasource.go
index 07fd0db..e41ccc6 100644
--- a/internal/datasources/browserpool/datasource.go
+++ b/internal/datasources/browserpool/datasource.go
@@ -1,10 +1,12 @@
package browserpool
import (
+ "bytes"
"context"
"encoding/json"
"fmt"
"strconv"
+ "strings"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/attr"
@@ -55,6 +57,7 @@ type browserPoolModel struct {
TimeoutSeconds types.Int64 `tfsdk:"timeout_seconds"`
FillRatePerMinute types.Int64 `tfsdk:"fill_rate_per_minute"`
Viewport types.Object `tfsdk:"viewport"`
+ ChromePolicy types.String `tfsdk:"chrome_policy"`
}
func NewDataSource() datasource.DataSource {
@@ -140,6 +143,10 @@ func (d *browserPoolDataSource) Schema(_ context.Context, _ datasource.SchemaReq
"refresh_rate": dschema.Int64Attribute{Computed: true, MarkdownDescription: "Display refresh rate in Hz, if configured."},
},
},
+ "chrome_policy": dschema.StringAttribute{
+ Computed: true,
+ MarkdownDescription: "Normalized JSON object of Chrome enterprise policy overrides, if configured.",
+ },
},
}
}
@@ -281,6 +288,7 @@ func flattenBrowserPool(pool kernel.BrowserPool) (browserPoolModel, diag.Diagnos
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),
Viewport: flattenViewport(config.JSON.Viewport.Raw(), config.JSON.Viewport.Valid(), config.Viewport, &diags),
+ ChromePolicy: flattenChromePolicy(config.JSON.ChromePolicy.Raw(), config.JSON.ChromePolicy.Valid(), &diags),
}, diags
}
@@ -384,6 +392,31 @@ func viewportAttributeTypes() map[string]attr.Type {
}
}
+func flattenChromePolicy(raw string, valid bool, diags *diag.Diagnostics) types.String {
+ if raw == "" || !datasources.FieldPresent(raw) {
+ return types.StringNull()
+ }
+ if !valid {
+ datasources.AddInvalidResponseField(diags, "Browser Pool", "browser_pool_config.chrome_policy")
+ return types.StringNull()
+ }
+
+ var policy map[string]any
+ if err := json.Unmarshal([]byte(raw), &policy); err != nil || policy == nil {
+ datasources.AddInvalidResponseField(diags, "Browser Pool", "browser_pool_config.chrome_policy")
+ return types.StringNull()
+ }
+
+ var normalized bytes.Buffer
+ encoder := json.NewEncoder(&normalized)
+ encoder.SetEscapeHTML(false)
+ if err := encoder.Encode(policy); err != nil {
+ datasources.AddInvalidResponseField(diags, "Browser Pool", "browser_pool_config.chrome_policy")
+ return types.StringNull()
+ }
+ return types.StringValue(strings.TrimSuffix(normalized.String(), "\n"))
+}
+
func flattenResolvedProfileID(pool kernel.BrowserPool, diags *diag.Diagnostics) types.String {
raw := pool.JSON.ProfileID.Raw()
if raw != "" {
diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go
index bdd714f..f1ac064 100644
--- a/internal/datasources/browserpool/datasource_test.go
+++ b/internal/datasources/browserpool/datasource_test.go
@@ -48,7 +48,7 @@ func TestDataSourceMetadataSchemaAndConfigure(t *testing.T) {
var schema datasource.SchemaResponse
ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema)
- for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "extension_ids", "proxy_id", "headless", "kiosk_mode", "stealth", "start_url", "timeout_seconds", "fill_rate_per_minute", "viewport"} {
+ for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "extension_ids", "proxy_id", "headless", "kiosk_mode", "stealth", "start_url", "timeout_seconds", "fill_rate_per_minute", "viewport", "chrome_policy"} {
if _, ok := schema.Schema.Attributes[name]; !ok {
t.Fatalf("schema missing %s", name)
}
@@ -213,7 +213,8 @@ func TestReadSetsTerraformState(t *testing.T) {
"start_url":"chrome://newtab",
"timeout_seconds":10,
"fill_rate_per_minute":0,
- "viewport":{"width":1280,"height":800,"refresh_rate":60}
+ "viewport":{"width":1280,"height":800,"refresh_rate":60},
+ "chrome_policy":{"RestoreOnStartup":4,"HomepageLocation":"https://example.com?x=1&y=2"}
}
}`), nil
},
@@ -254,6 +255,51 @@ func TestReadSetsTerraformState(t *testing.T) {
t.Fatalf("warmup state = %#v", state)
}
assertBrowserPoolViewport(t, state.Viewport, 1280, 800, types.Int64Value(60))
+ if state.ChromePolicy.ValueString() != `{"HomepageLocation":"https://example.com?x=1&y=2","RestoreOnStartup":4}` {
+ t.Fatalf("chrome_policy = %q", state.ChromePolicy.ValueString())
+ }
+}
+
+func TestFlattenBrowserPoolChromePolicyNormalization(t *testing.T) {
+ t.Parallel()
+
+ omitted, diags := flattenBrowserPool(*browserPoolFromJSON(`{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1}}`))
+ if diags.HasError() {
+ t.Fatalf("unexpected omitted policy diagnostics: %v", diags)
+ }
+ if !omitted.ChromePolicy.IsNull() {
+ t.Fatalf("omitted chrome_policy = %v, want null", omitted.ChromePolicy)
+ }
+ explicitNull, diags := flattenBrowserPool(*browserPoolFromJSON(`{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"chrome_policy":null}}`))
+ if diags.HasError() || !explicitNull.ChromePolicy.IsNull() {
+ t.Fatalf("explicit-null chrome_policy = %v, diagnostics = %v", explicitNull.ChromePolicy, diags)
+ }
+
+ state, diags := flattenBrowserPool(*browserPoolFromJSON(`{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"chrome_policy":{"Tag":"","Number":1.0,"Nested":{"enabled":true}}}}`))
+ if diags.HasError() {
+ t.Fatalf("unexpected policy diagnostics: %v", diags)
+ }
+ want := `{"Nested":{"enabled":true},"Number":1,"Tag":""}`
+ if state.ChromePolicy.ValueString() != want {
+ t.Fatalf("chrome_policy = %q, want %q", state.ChromePolicy.ValueString(), want)
+ }
+}
+
+func TestFlattenBrowserPoolRejectsInvalidChromePolicy(t *testing.T) {
+ t.Parallel()
+
+ for name, body := range map[string]string{
+ "array": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"chrome_policy":[]}}`,
+ "string": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"chrome_policy":"policy"}}`,
+ } {
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+ _, diags := flattenBrowserPool(*browserPoolFromJSON(body))
+ if !diags.HasError() {
+ t.Fatal("expected diagnostics")
+ }
+ })
+ }
}
func TestFlattenBrowserPoolViewportOptionalFields(t *testing.T) {
@@ -616,6 +662,7 @@ func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value {
"timeout_seconds": tftypes.Number,
"fill_rate_per_minute": tftypes.Number,
"viewport": viewportType,
+ "chrome_policy": tftypes.String,
}},
map[string]tftypes.Value{
"id": id,
@@ -632,6 +679,7 @@ func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value {
"timeout_seconds": tftypes.NewValue(tftypes.Number, nil),
"fill_rate_per_minute": tftypes.NewValue(tftypes.Number, nil),
"viewport": tftypes.NewValue(viewportType, nil),
+ "chrome_policy": tftypes.NewValue(tftypes.String, nil),
},
)
}
From 42dcc73825415754df25d5cbc83909bc0d224b8f Mon Sep 17 00:00:00 2001
From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com>
Date: Mon, 3 Aug 2026 10:23:46 -0400
Subject: [PATCH 13/18] test Chrome policy schema mutations
---
internal/datasources/browserpool/datasource_test.go | 1 +
1 file changed, 1 insertion(+)
diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go
index f1ac064..bd1c8a9 100644
--- a/internal/datasources/browserpool/datasource_test.go
+++ b/internal/datasources/browserpool/datasource_test.go
@@ -99,6 +99,7 @@ func TestDataSourceSchemaSemantics(t *testing.T) {
for _, name := range []string{"width", "height", "refresh_rate"} {
assertAttributeMapMode(t, viewport.Attributes, name, false, true)
}
+ assertAttributeMode(t, resp.Schema, "chrome_policy", false, true)
projectID := resp.Schema.Attributes["project_id"].(dschema.StringAttribute)
if !validateProjectID(projectID.Validators, "").HasError() {
From eef1ff433edd88deecb8321a77dd806681eadb1b Mon Sep 17 00:00:00 2001
From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com>
Date: Sat, 11 Jul 2026 08:09:44 -0400
Subject: [PATCH 14/18] Test browser pool data source against Kernel
Add opt-in acceptance coverage for ID and exact-name lookup, durable state flattening, no-drift planning, project overrides, and cleanup. Run it only from the manual acceptance matrix.
---
.github/workflows/acceptance.yml | 3 +
docs/acceptance.md | 15 +-
.../browserpool/datasource_acc_test.go | 147 ++++++++++++++++++
3 files changed, 158 insertions(+), 7 deletions(-)
create mode 100644 internal/datasources/browserpool/datasource_acc_test.go
diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml
index e9073d5..5503dbd 100644
--- a/.github/workflows/acceptance.yml
+++ b/.github/workflows/acceptance.yml
@@ -24,6 +24,9 @@ jobs:
- name: Browser pool
package: ./internal/resources/browserpool
project_id_required: true
+ - name: Browser pool data source
+ package: ./internal/datasources/browserpool
+ project_id_required: true
- name: Project
package: ./internal/resources/project
project_id_required: false
diff --git a/docs/acceptance.md b/docs/acceptance.md
index e2f7b8a..e84fa55 100644
--- a/docs/acceptance.md
+++ b/docs/acceptance.md
@@ -4,7 +4,7 @@ This document is the live-API release gate for the provider's selected public
surface. Unit tests remain the fast default. Acceptance tests run after changes
reach `main`, through explicit local opt-in, or by manual workflow dispatch.
-The selected surface contains two managed resources and four read-only data
+The selected surface contains two managed resources and five read-only data
sources. It does not claim coverage for future or unregistered Kernel objects.
## Gate Rules
@@ -29,7 +29,7 @@ export KERNEL_ALT_PROJECT_ID=... # optional second project
export KERNEL_BASE_URL=... # optional non-production API
```
-`KERNEL_PROJECT_ID` is required for the browser-pool resource and all four data
+`KERNEL_PROJECT_ID` is required for the browser-pool resource and all five data
sources. The project resource is organization-scoped and does not require it.
## Matrix
@@ -38,6 +38,7 @@ sources. The project resource is organization-scoped and does not require it.
| --- | --- | --- |
| `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` data source | `./internal/datasources/browserpool` | Create a unique browser-pool fixture, read it by canonical ID and exact name with explicit project scope, verify normalized durable configuration and no drift, then delete and require coded `not_found`. |
| `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`. |
@@ -53,21 +54,21 @@ Run packages independently for fast failure isolation:
```sh
go test -count=1 -timeout=30m -v ./internal/resources/project -run TestAcc
go test -count=1 -timeout=30m -v ./internal/resources/browserpool -run TestAcc
+go test -count=1 -timeout=30m -v ./internal/datasources/browserpool -run TestAcc
go test -count=1 -timeout=30m -v ./internal/datasources/project -run TestAcc
go test -count=1 -timeout=30m -v ./internal/datasources/profile -run TestAcc
go test -count=1 -timeout=30m -v ./internal/datasources/proxy -run TestAcc
go test -count=1 -timeout=30m -v ./internal/datasources/extension -run TestAcc
```
-The `Acceptance` workflow runs the same six packages as separate matrix jobs
+The `Acceptance` workflow runs the same seven packages as separate matrix jobs
with `fail-fast: false` after changes reach `main` and on manual dispatch.
## Outside The Selected Surface
-The release does not include a browser-pool data source or profile, proxy,
-extension, deployment, app, or API-key resources. Runtime/session operations
-remain outside Terraform. Unregistered surfaces are not acceptance blockers for
-this selected release.
+The release does not include profile, proxy, extension, deployment, app, or
+API-key resources. Runtime/session operations remain outside Terraform.
+Unregistered surfaces are not acceptance blockers for this selected release.
## Release Record
diff --git a/internal/datasources/browserpool/datasource_acc_test.go b/internal/datasources/browserpool/datasource_acc_test.go
new file mode 100644
index 0000000..002386d
--- /dev/null
+++ b/internal/datasources/browserpool/datasource_acc_test.go
@@ -0,0 +1,147 @@
+package browserpool_test
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/hashicorp/terraform-plugin-testing/helper/resource"
+ "github.com/hashicorp/terraform-plugin-testing/terraform"
+ "github.com/kernel/terraform-provider-kernel/internal/acctest"
+ "github.com/kernel/terraform-provider-kernel/internal/projectscope"
+)
+
+const browserPoolAcceptanceResourceName = "kernel_browser_pool.data_source_test"
+
+func TestAccBrowserPoolDataSourceByIDAndName(t *testing.T) {
+ name := acctest.UniqueName(t, "browser-pool-data")
+ projectID := os.Getenv(acctest.EnvAltProjectID)
+ if projectID == "" {
+ projectID = os.Getenv(acctest.EnvProjectID)
+ }
+ config := testAccBrowserPoolDataSourceConfig(name, projectID)
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() {
+ acctest.PreCheck(t)
+ if projectID == "" {
+ t.Fatalf("%s or %s must be set for the browser pool data source acceptance test", acctest.EnvProjectID, acctest.EnvAltProjectID)
+ }
+ },
+ ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories(),
+ CheckDestroy: testAccCheckBrowserPoolDataSourceDestroyed(),
+ Steps: []resource.TestStep{
+ {
+ Config: config,
+ Check: resource.ComposeAggregateTestCheckFunc(
+ testAccCaptureBrowserPoolDataSourceID(t),
+ resource.TestCheckResourceAttrPair("data.kernel_browser_pool.by_id", "id", browserPoolAcceptanceResourceName, "id"),
+ resource.TestCheckResourceAttrPair("data.kernel_browser_pool.by_name", "id", browserPoolAcceptanceResourceName, "id"),
+ resource.TestCheckResourceAttrPair("data.kernel_browser_pool.by_id", "name", browserPoolAcceptanceResourceName, "name"),
+ resource.TestCheckResourceAttr("data.kernel_browser_pool.by_id", "project_id", projectID),
+ resource.TestCheckResourceAttr("data.kernel_browser_pool.by_name", "project_id", projectID),
+ testAccCheckBrowserPoolDataSourceState("data.kernel_browser_pool.by_id", name),
+ testAccCheckBrowserPoolDataSourceState("data.kernel_browser_pool.by_name", name),
+ ),
+ },
+ {
+ Config: config,
+ PlanOnly: true,
+ },
+ },
+ })
+}
+
+func testAccCheckBrowserPoolDataSourceDestroyed() resource.TestCheckFunc {
+ return func(state *terraform.State) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ client := acctest.ClientFromEnv()
+ for _, resourceState := range state.RootModule().Resources {
+ if resourceState.Type != "kernel_browser_pool" || resourceState.Primary == nil || resourceState.Primary.ID == "" {
+ continue
+ }
+
+ _, err := client.GetBrowserPool(ctx, resourceState.Primary.Attributes["project_id"], resourceState.Primary.ID)
+ if projectscope.IsNotFound(err) {
+ continue
+ }
+ if err != nil {
+ return fmt.Errorf("read Kernel browser pool %s after destroy: %w", resourceState.Primary.ID, err)
+ }
+ return fmt.Errorf("Kernel browser pool %s still exists after destroy", resourceState.Primary.ID)
+ }
+ return nil
+ }
+}
+
+func testAccBrowserPoolDataSourceConfig(name, projectID string) string {
+ return acctest.ProviderConfig() + fmt.Sprintf(`
+resource "kernel_browser_pool" "data_source_test" {
+ name = %[1]q
+ size = 1
+ project_id = %[2]q
+ start_url = "chrome://newtab"
+ headless = true
+ kiosk_mode = false
+ stealth = false
+ timeout_seconds = 90
+ fill_rate_per_minute = 0
+ viewport = {
+ width = 1280
+ height = 800
+ refresh_rate = 60
+ }
+ chrome_policy = jsonencode({
+ HomepageLocation = "https://example.com"
+ RestoreOnStartup = 4
+ })
+}
+
+data "kernel_browser_pool" "by_id" {
+ id = kernel_browser_pool.data_source_test.id
+ project_id = %[2]q
+}
+
+data "kernel_browser_pool" "by_name" {
+ name = kernel_browser_pool.data_source_test.name
+ project_id = %[2]q
+}
+`, name, projectID)
+}
+
+func testAccCaptureBrowserPoolDataSourceID(t *testing.T) resource.TestCheckFunc {
+ t.Helper()
+
+ return func(state *terraform.State) error {
+ resourceState, ok := state.RootModule().Resources[browserPoolAcceptanceResourceName]
+ if !ok || resourceState.Primary == nil || resourceState.Primary.ID == "" {
+ return fmt.Errorf("missing ID for %s", browserPoolAcceptanceResourceName)
+ }
+ acctest.CleanupBrowserPool(t, resourceState.Primary.Attributes["project_id"], resourceState.Primary.ID)
+ return nil
+ }
+}
+
+func testAccCheckBrowserPoolDataSourceState(resourceName, name string) resource.TestCheckFunc {
+ return resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr(resourceName, "name", name),
+ resource.TestCheckResourceAttr(resourceName, "size", "1"),
+ resource.TestCheckResourceAttr(resourceName, "start_url", "chrome://newtab"),
+ resource.TestCheckResourceAttr(resourceName, "headless", "true"),
+ resource.TestCheckResourceAttr(resourceName, "kiosk_mode", "false"),
+ resource.TestCheckResourceAttr(resourceName, "stealth", "false"),
+ resource.TestCheckResourceAttr(resourceName, "timeout_seconds", "90"),
+ resource.TestCheckResourceAttr(resourceName, "fill_rate_per_minute", "0"),
+ resource.TestCheckResourceAttr(resourceName, "viewport.width", "1280"),
+ resource.TestCheckResourceAttr(resourceName, "viewport.height", "800"),
+ resource.TestCheckResourceAttr(resourceName, "viewport.refresh_rate", "60"),
+ resource.TestCheckResourceAttr(resourceName, "chrome_policy", `{"HomepageLocation":"https://example.com","RestoreOnStartup":4}`),
+ resource.TestCheckResourceAttr(resourceName, "extension_ids.#", "0"),
+ resource.TestCheckNoResourceAttr(resourceName, "profile_id"),
+ resource.TestCheckNoResourceAttr(resourceName, "proxy_id"),
+ )
+}
From e43bf84e23fc966bde82650fd18910b8c17f6490 Mon Sep 17 00:00:00 2001
From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com>
Date: Fri, 31 Jul 2026 17:33:29 -0400
Subject: [PATCH 15/18] Add browser pool profile refresh policy
Expose Kernel's durable refresh_on_profile_update setting through the browser pool resource. Preserve API defaults when omitted, retain explicit false values across profile changes, validate the profile dependency, and cover create/update/read behavior.
---
docs/resources/browser_pool.md | 1 +
internal/resources/browserpool/expand.go | 9 +-
internal/resources/browserpool/expand_test.go | 116 ++++++++++++++++++
internal/resources/browserpool/flatten.go | 1 +
.../resources/browserpool/flatten_test.go | 14 +++
internal/resources/browserpool/model.go | 1 +
.../browserpool/resource_acc_test.go | 2 +
internal/resources/browserpool/schema.go | 8 ++
internal/resources/browserpool/schema_test.go | 100 ++++++++++++---
internal/resources/browserpool/validators.go | 31 +++++
10 files changed, 267 insertions(+), 16 deletions(-)
diff --git a/docs/resources/browser_pool.md b/docs/resources/browser_pool.md
index 96ac189..204161b 100644
--- a/docs/resources/browser_pool.md
+++ b/docs/resources/browser_pool.md
@@ -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.
+- `refresh_on_profile_update` (Boolean) When true, idle browsers are refreshed when the pool's profile is updated. Requires `profile_id` to be set.
- `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.
diff --git a/internal/resources/browserpool/expand.go b/internal/resources/browserpool/expand.go
index aabc3e0..55f5e04 100644
--- a/internal/resources/browserpool/expand.go
+++ b/internal/resources/browserpool/expand.go
@@ -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.RefreshOnProfile) {
+ params.RefreshOnProfileUpdate = kernel.Bool(model.RefreshOnProfile.ValueBool())
+ }
if isKnownString(model.ProxyID) {
params.ProxyID = kernel.String(model.ProxyID.ValueString())
}
@@ -115,9 +118,13 @@ func expandUpdateParams(ctx context.Context, plan, state browserPoolModel) (kern
if !plan.Size.Equal(state.Size) {
params.Size = kernel.Int(plan.Size.ValueInt64())
}
- if !plan.ProfileID.Equal(state.ProfileID) && isKnownString(plan.ProfileID) {
+ profileChanged := !plan.ProfileID.Equal(state.ProfileID)
+ if profileChanged && isKnownString(plan.ProfileID) {
params.Profile.ID = kernel.String(plan.ProfileID.ValueString())
}
+ if (!plan.RefreshOnProfile.Equal(state.RefreshOnProfile) || profileChanged) && isKnownBool(plan.RefreshOnProfile) {
+ params.RefreshOnProfileUpdate = kernel.Bool(plan.RefreshOnProfile.ValueBool())
+ }
if !plan.ProxyID.Equal(state.ProxyID) {
if plan.ProxyID.IsNull() {
params.ProxyID = kernel.String("")
diff --git a/internal/resources/browserpool/expand_test.go b/internal/resources/browserpool/expand_test.go
index 8cee668..6c78f9a 100644
--- a/internal/resources/browserpool/expand_test.go
+++ b/internal/resources/browserpool/expand_test.go
@@ -58,9 +58,33 @@ func TestExpandCreateParamsMapsDurableConfigToSDK(t *testing.T) {
}
}
+func TestExpandCreateParamsMapsRefreshOnProfileUpdateFalse(t *testing.T) {
+ model := browserPoolModel{
+ Size: types.Int64Value(1),
+ ProfileID: types.StringValue("profile-1"),
+ RefreshOnProfile: types.BoolValue(false),
+ }
+
+ params, diags := expandCreateParams(context.Background(), model)
+ if diags.HasError() {
+ t.Fatalf("unexpected diagnostics: %v", diags)
+ }
+
+ body := marshalSDKParams(t, params)
+ want := map[string]any{
+ "size": float64(1),
+ "profile": map[string]any{"id": "profile-1"},
+ "refresh_on_profile_update": false,
+ }
+ if !jsonEqual(t, body, want) {
+ t.Fatalf("expanded SDK JSON mismatch\ngot: %#v\nwant: %#v", body, want)
+ }
+}
+
func TestExpandCreateParamsOmitsUnknownServerDefaults(t *testing.T) {
model := browserPoolModel{
Size: types.Int64Value(1),
+ RefreshOnProfile: types.BoolUnknown(),
Headless: types.BoolUnknown(),
KioskMode: types.BoolUnknown(),
Stealth: types.BoolUnknown(),
@@ -331,6 +355,98 @@ func TestExpandUpdateParamsMapsChangedDurableConfigToSDKPatch(t *testing.T) {
}
}
+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, diags := expandUpdateParams(
+ context.Background(),
+ refreshOnProfileUpdateModel(test.plan),
+ refreshOnProfileUpdateModel(test.state),
+ )
+ if diags.HasError() {
+ t.Fatalf("unexpected diagnostics: %v", diags)
+ }
+
+ 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, diags := expandUpdateParams(context.Background(), model, model)
+ if diags.HasError() {
+ t.Fatalf("unexpected diagnostics: %v", diags)
+ }
+
+ if body := marshalSDKParams(t, params); len(body) != 0 {
+ t.Fatalf("update params = %#v, want empty patch for unchanged refresh_on_profile_update", body)
+ }
+}
+
+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, diags := expandUpdateParams(context.Background(), plan, state)
+ if diags.HasError() {
+ t.Fatalf("unexpected diagnostics: %v", diags)
+ }
+
+ 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 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, diags := expandUpdateParams(context.Background(), plan, state)
+ if diags.HasError() {
+ t.Fatalf("unexpected diagnostics: %v", diags)
+ }
+
+ 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 {
+ return browserPoolModel{
+ Size: types.Int64Value(1),
+ RefreshOnProfile: value,
+ ExtensionIDs: types.ListNull(types.StringType),
+ ChromePolicy: chromePolicyNull(),
+ Viewport: types.ObjectNull(viewportAttrTypes()),
+ }
+}
+
func TestExpandUpdateParamsOmitsUnchangedDurableConfig(t *testing.T) {
model := browserPoolModel{
Name: types.StringValue("pool-a"),
diff --git a/internal/resources/browserpool/flatten.go b/internal/resources/browserpool/flatten.go
index 18934f4..5afcaaa 100644
--- a/internal/resources/browserpool/flatten.go
+++ b/internal/resources/browserpool/flatten.go
@@ -31,6 +31,7 @@ func flattenBrowserPool(pool kernel.BrowserPool, base browserPoolModel) (browser
Name: flattenName(pool, &diags),
Size: types.Int64Value(config.Size),
ProfileID: flattenResolvedProfileID(pool, config, &diags),
+ RefreshOnProfile: 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),
diff --git a/internal/resources/browserpool/flatten_test.go b/internal/resources/browserpool/flatten_test.go
index 56cea22..1b0318f 100644
--- a/internal/resources/browserpool/flatten_test.go
+++ b/internal/resources/browserpool/flatten_test.go
@@ -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
@@ -74,6 +75,9 @@ func TestFlattenBrowserPoolMapsDurableState(t *testing.T) {
if got.Stealth.ValueBool() {
t.Fatal("stealth = true, want false")
}
+ if !got.RefreshOnProfile.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())
}
@@ -177,6 +181,9 @@ func TestFlattenBrowserPoolNullsOmittedOptionalFields(t *testing.T) {
if !got.Stealth.IsNull() {
t.Fatalf("stealth = %#v, want null", got.Stealth)
}
+ if !got.RefreshOnProfile.IsNull() {
+ t.Fatalf("refresh_on_profile_update = %#v, want null", got.RefreshOnProfile)
+ }
assertStringNull(t, "start_url", got.StartURL)
if !got.TimeoutSeconds.IsNull() {
t.Fatalf("timeout_seconds = %#v, want null", got.TimeoutSeconds)
@@ -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": {
diff --git a/internal/resources/browserpool/model.go b/internal/resources/browserpool/model.go
index 3f5d5b4..fd8ab98 100644
--- a/internal/resources/browserpool/model.go
+++ b/internal/resources/browserpool/model.go
@@ -8,6 +8,7 @@ type browserPoolModel struct {
ProjectID types.String `tfsdk:"project_id"`
Size types.Int64 `tfsdk:"size"`
ProfileID types.String `tfsdk:"profile_id"`
+ RefreshOnProfile types.Bool `tfsdk:"refresh_on_profile_update"`
ProxyID types.String `tfsdk:"proxy_id"`
ExtensionIDs types.List `tfsdk:"extension_ids"`
ChromePolicy chromePolicyValue `tfsdk:"chrome_policy"`
diff --git a/internal/resources/browserpool/resource_acc_test.go b/internal/resources/browserpool/resource_acc_test.go
index 5b176c4..c1d2055 100644
--- a/internal/resources/browserpool/resource_acc_test.go
+++ b/internal/resources/browserpool/resource_acc_test.go
@@ -38,6 +38,7 @@ func TestAccBrowserPoolLifecycle(t *testing.T) {
resource.TestCheckResourceAttr(browserPoolResourceName, "headless", "true"),
resource.TestCheckResourceAttr(browserPoolResourceName, "kiosk_mode", "false"),
resource.TestCheckResourceAttr(browserPoolResourceName, "stealth", "false"),
+ resource.TestCheckResourceAttr(browserPoolResourceName, "refresh_on_profile_update", "false"),
resource.TestCheckResourceAttr(browserPoolResourceName, "timeout_seconds", "90"),
resource.TestCheckResourceAttr(browserPoolResourceName, "fill_rate_per_minute", "0"),
),
@@ -133,6 +134,7 @@ resource "kernel_browser_pool" "test" {
headless = true
kiosk_mode = false
stealth = false
+ refresh_on_profile_update = false
timeout_seconds = 90
fill_rate_per_minute = 0
}
diff --git a/internal/resources/browserpool/schema.go b/internal/resources/browserpool/schema.go
index 00415b0..87eaed3 100644
--- a/internal/resources/browserpool/schema.go
+++ b/internal/resources/browserpool/schema.go
@@ -56,6 +56,14 @@ func BrowserPoolSchema() rschema.Schema {
stringvalidator.LengthAtLeast(1),
},
},
+ "refresh_on_profile_update": rschema.BoolAttribute{
+ Optional: true,
+ Computed: true,
+ MarkdownDescription: "When true, idle browsers are refreshed when the pool's profile is updated. Requires `profile_id` to be set.",
+ Validators: []validator.Bool{
+ refreshOnProfileUpdateValidator{},
+ },
+ },
"proxy_id": rschema.StringAttribute{
Optional: true,
MarkdownDescription: "Optional proxy ID to use for browsers created by this pool.",
diff --git a/internal/resources/browserpool/schema_test.go b/internal/resources/browserpool/schema_test.go
index 9c691f6..bb4c774 100644
--- a/internal/resources/browserpool/schema_test.go
+++ b/internal/resources/browserpool/schema_test.go
@@ -21,21 +21,22 @@ func TestSchemaContainsOnlyDurableAttributes(t *testing.T) {
s := BrowserPoolSchema()
want := map[string]struct{}{
- "id": {},
- "name": {},
- "project_id": {},
- "size": {},
- "profile_id": {},
- "proxy_id": {},
- "extension_ids": {},
- "chrome_policy": {},
- "viewport": {},
- "headless": {},
- "kiosk_mode": {},
- "stealth": {},
- "start_url": {},
- "timeout_seconds": {},
- "fill_rate_per_minute": {},
+ "id": {},
+ "name": {},
+ "project_id": {},
+ "size": {},
+ "profile_id": {},
+ "refresh_on_profile_update": {},
+ "proxy_id": {},
+ "extension_ids": {},
+ "chrome_policy": {},
+ "viewport": {},
+ "headless": {},
+ "kiosk_mode": {},
+ "stealth": {},
+ "start_url": {},
+ "timeout_seconds": {},
+ "fill_rate_per_minute": {},
}
for name := range want {
@@ -87,6 +88,9 @@ func TestSchemaRequiredComputedOptionalSemantics(t *testing.T) {
assertBoolAttribute(t, s, "stealth", func(attr rschema.BoolAttribute) bool {
return attr.Optional && attr.Computed && !attr.Required
})
+ assertBoolAttribute(t, s, "refresh_on_profile_update", func(attr rschema.BoolAttribute) bool {
+ return attr.Optional && attr.Computed && !attr.Required
+ })
assertInt64Attribute(t, s, "timeout_seconds", func(attr rschema.Int64Attribute) bool {
return attr.Optional && attr.Computed && !attr.Required
})
@@ -216,6 +220,72 @@ func TestSchemaValidatesProfileIDNonEmpty(t *testing.T) {
assertStringAccepts(t, attr, "profile_id", "profile-1")
}
+func TestSchemaValidatesRefreshOnProfileUpdateRequiresProfile(t *testing.T) {
+ attr := boolAttribute(t, BrowserPoolSchema(), "refresh_on_profile_update")
+ tests := map[string]struct {
+ refresh types.Bool
+ profileID tftypes.Value
+ wantError bool
+ }{
+ "true without profile": {
+ refresh: types.BoolValue(true),
+ profileID: tftypes.NewValue(tftypes.String, nil),
+ wantError: true,
+ },
+ "false without profile": {
+ refresh: types.BoolValue(false),
+ profileID: tftypes.NewValue(tftypes.String, nil),
+ },
+ "true with profile": {
+ refresh: types.BoolValue(true),
+ profileID: tftypes.NewValue(tftypes.String, "profile-1"),
+ },
+ "true with unknown profile": {
+ refresh: types.BoolValue(true),
+ profileID: tftypes.NewValue(tftypes.String, tftypes.UnknownValue),
+ },
+ "unknown without profile": {
+ refresh: types.BoolUnknown(),
+ profileID: tftypes.NewValue(tftypes.String, nil),
+ },
+ }
+
+ for name, test := range tests {
+ t.Run(name, func(t *testing.T) {
+ diags := validateRefreshOnProfileUpdate(attr.BoolValidators(), test.refresh, test.profileID)
+ if test.wantError && !diags.HasError() {
+ t.Fatal("expected validation error")
+ }
+ if !test.wantError && diags.HasError() {
+ t.Fatalf("unexpected validation error: %v", diags)
+ }
+ })
+ }
+}
+
+func validateRefreshOnProfileUpdate(validators []validator.Bool, refresh types.Bool, profileID tftypes.Value) diag.Diagnostics {
+ profileSchema := rschema.Schema{Attributes: map[string]rschema.Attribute{
+ "profile_id": rschema.StringAttribute{Optional: true},
+ }}
+ config := tfsdk.Config{
+ Schema: profileSchema,
+ Raw: tftypes.NewValue(
+ profileSchema.Type().TerraformType(context.Background()),
+ map[string]tftypes.Value{"profile_id": profileID},
+ ),
+ }
+ req := validator.BoolRequest{
+ Path: path.Root("refresh_on_profile_update"),
+ Config: config,
+ ConfigValue: refresh,
+ }
+ var resp validator.BoolResponse
+ for _, v := range validators {
+ v.ValidateBool(context.Background(), req, &resp)
+ }
+ return resp.Diagnostics
+}
+
func TestSchemaValidatesProxyIDNonEmpty(t *testing.T) {
attr := stringAttribute(t, BrowserPoolSchema(), "proxy_id")
diff --git a/internal/resources/browserpool/validators.go b/internal/resources/browserpool/validators.go
index d60d169..b9d8c5f 100644
--- a/internal/resources/browserpool/validators.go
+++ b/internal/resources/browserpool/validators.go
@@ -5,12 +5,15 @@ import (
"fmt"
"regexp"
+ "github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/types"
)
var (
_ validator.String = browserPoolNameValidator{}
_ validator.String = chromePolicyJSONValidator{}
+ _ validator.Bool = refreshOnProfileUpdateValidator{}
)
var (
@@ -49,6 +52,34 @@ func (browserPoolNameValidator) ValidateString(_ context.Context, req validator.
)
}
+type refreshOnProfileUpdateValidator struct{}
+
+func (refreshOnProfileUpdateValidator) Description(context.Context) string {
+ return "refresh_on_profile_update can be true only when profile_id is set"
+}
+
+func (v refreshOnProfileUpdateValidator) MarkdownDescription(ctx context.Context) string {
+ return v.Description(ctx)
+}
+
+func (refreshOnProfileUpdateValidator) ValidateBool(ctx context.Context, req validator.BoolRequest, resp *validator.BoolResponse) {
+ if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() || !req.ConfigValue.ValueBool() {
+ return
+ }
+
+ var profileID types.String
+ resp.Diagnostics.Append(req.Config.GetAttribute(ctx, path.Root("profile_id"), &profileID)...)
+ if resp.Diagnostics.HasError() || profileID.IsUnknown() || !profileID.IsNull() {
+ return
+ }
+
+ resp.Diagnostics.AddAttributeError(
+ req.Path,
+ "Missing Browser Pool Profile",
+ "refresh_on_profile_update can be true only when profile_id is set.",
+ )
+}
+
type chromePolicyJSONValidator struct{}
func (chromePolicyJSONValidator) Description(context.Context) string {
From d0c08620675a8012aa4d79518d5da0de14abb637 Mon Sep 17 00:00:00 2001
From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com>
Date: Fri, 31 Jul 2026 17:37:56 -0400
Subject: [PATCH 16/18] Expose browser pool profile refresh state
Return refresh_on_profile_update from kernel_browser_pool lookups so imported and externally managed pools expose the same durable policy as the resource. Validate API response types and cover ID/name acceptance state.
---
docs/data-sources/browser_pool.md | 1 +
.../datasources/browserpool/datasource.go | 6 ++
.../browserpool/datasource_acc_test.go | 2 +
.../browserpool/datasource_test.go | 99 +++++++++++--------
4 files changed, 67 insertions(+), 41 deletions(-)
diff --git a/docs/data-sources/browser_pool.md b/docs/data-sources/browser_pool.md
index 7b803bf..f5bc60b 100644
--- a/docs/data-sources/browser_pool.md
+++ b/docs/data-sources/browser_pool.md
@@ -30,6 +30,7 @@ Lookup durable Kernel browser pool configuration.
- `kiosk_mode` (Boolean) Whether browsers launch in kiosk mode.
- `profile_id` (String) Resolved profile ID attached to the pool, if any.
- `proxy_id` (String) Proxy ID attached to browsers in the pool, if any.
+- `refresh_on_profile_update` (Boolean) Whether idle browsers are refreshed when the pool's profile is updated.
- `size` (Number) Number of browsers maintained in the pool.
- `start_url` (String) URL opened when a browser is warmed into the pool, if configured.
- `stealth` (Boolean) Whether browsers launch in stealth mode.
diff --git a/internal/datasources/browserpool/datasource.go b/internal/datasources/browserpool/datasource.go
index e41ccc6..0d04640 100644
--- a/internal/datasources/browserpool/datasource.go
+++ b/internal/datasources/browserpool/datasource.go
@@ -48,6 +48,7 @@ type browserPoolModel struct {
ProjectID types.String `tfsdk:"project_id"`
Size types.Int64 `tfsdk:"size"`
ProfileID types.String `tfsdk:"profile_id"`
+ RefreshOnProfile types.Bool `tfsdk:"refresh_on_profile_update"`
ExtensionIDs types.List `tfsdk:"extension_ids"`
ProxyID types.String `tfsdk:"proxy_id"`
Headless types.Bool `tfsdk:"headless"`
@@ -101,6 +102,10 @@ func (d *browserPoolDataSource) Schema(_ context.Context, _ datasource.SchemaReq
Computed: true,
MarkdownDescription: "Resolved profile ID attached to the pool, if any.",
},
+ "refresh_on_profile_update": dschema.BoolAttribute{
+ Computed: true,
+ MarkdownDescription: "Whether idle browsers are refreshed when the pool's profile is updated.",
+ },
"extension_ids": dschema.ListAttribute{
Computed: true,
ElementType: types.StringType,
@@ -279,6 +284,7 @@ func flattenBrowserPool(pool kernel.BrowserPool) (browserPoolModel, diag.Diagnos
Name: name,
Size: types.Int64Value(config.Size),
ProfileID: flattenResolvedProfileID(pool, &diags),
+ RefreshOnProfile: flattenOptionalBool("browser_pool_config.refresh_on_profile_update", config.JSON.RefreshOnProfileUpdate.Raw(), config.JSON.RefreshOnProfileUpdate.Valid(), config.RefreshOnProfileUpdate, &diags),
ExtensionIDs: flattenResolvedExtensionIDs(pool, &diags),
ProxyID: flattenOptionalString("browser_pool_config.proxy_id", config.JSON.ProxyID.Raw(), config.JSON.ProxyID.Valid(), config.ProxyID, &diags),
Headless: flattenOptionalBool("browser_pool_config.headless", config.JSON.Headless.Raw(), config.JSON.Headless.Valid(), config.Headless, &diags),
diff --git a/internal/datasources/browserpool/datasource_acc_test.go b/internal/datasources/browserpool/datasource_acc_test.go
index 002386d..67806c3 100644
--- a/internal/datasources/browserpool/datasource_acc_test.go
+++ b/internal/datasources/browserpool/datasource_acc_test.go
@@ -88,6 +88,7 @@ resource "kernel_browser_pool" "data_source_test" {
headless = true
kiosk_mode = false
stealth = false
+ refresh_on_profile_update = false
timeout_seconds = 90
fill_rate_per_minute = 0
viewport = {
@@ -134,6 +135,7 @@ func testAccCheckBrowserPoolDataSourceState(resourceName, name string) resource.
resource.TestCheckResourceAttr(resourceName, "headless", "true"),
resource.TestCheckResourceAttr(resourceName, "kiosk_mode", "false"),
resource.TestCheckResourceAttr(resourceName, "stealth", "false"),
+ resource.TestCheckResourceAttr(resourceName, "refresh_on_profile_update", "false"),
resource.TestCheckResourceAttr(resourceName, "timeout_seconds", "90"),
resource.TestCheckResourceAttr(resourceName, "fill_rate_per_minute", "0"),
resource.TestCheckResourceAttr(resourceName, "viewport.width", "1280"),
diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go
index bd1c8a9..045a06b 100644
--- a/internal/datasources/browserpool/datasource_test.go
+++ b/internal/datasources/browserpool/datasource_test.go
@@ -48,7 +48,7 @@ func TestDataSourceMetadataSchemaAndConfigure(t *testing.T) {
var schema datasource.SchemaResponse
ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema)
- for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "extension_ids", "proxy_id", "headless", "kiosk_mode", "stealth", "start_url", "timeout_seconds", "fill_rate_per_minute", "viewport", "chrome_policy"} {
+ for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "refresh_on_profile_update", "extension_ids", "proxy_id", "headless", "kiosk_mode", "stealth", "start_url", "timeout_seconds", "fill_rate_per_minute", "viewport", "chrome_policy"} {
if _, ok := schema.Schema.Attributes[name]; !ok {
t.Fatalf("schema missing %s", name)
}
@@ -211,6 +211,7 @@ func TestReadSetsTerraformState(t *testing.T) {
"headless":true,
"kiosk_mode":false,
"stealth":true,
+ "refresh_on_profile_update":true,
"start_url":"chrome://newtab",
"timeout_seconds":10,
"fill_rate_per_minute":0,
@@ -252,6 +253,9 @@ func TestReadSetsTerraformState(t *testing.T) {
if state.ProxyID.ValueString() != "proxy-1" || !state.Headless.ValueBool() || state.KioskMode.IsNull() || state.KioskMode.ValueBool() || !state.Stealth.ValueBool() {
t.Fatalf("launch state = %#v", state)
}
+ if !state.RefreshOnProfile.ValueBool() {
+ t.Fatal("refresh_on_profile_update = false, want true")
+ }
if state.StartURL.ValueString() != "chrome://newtab" || state.TimeoutSeconds.ValueInt64() != 10 || state.FillRatePerMinute.IsNull() || state.FillRatePerMinute.IsUnknown() || state.FillRatePerMinute.ValueInt64() != 0 {
t.Fatalf("warmup state = %#v", state)
}
@@ -407,6 +411,11 @@ func TestFlattenBrowserPoolRejectsInvalidWarmupConfiguration(t *testing.T) {
func TestFlattenBrowserPoolLaunchConfiguration(t *testing.T) {
t.Parallel()
+ omitted, diags := flattenBrowserPool(*browserPoolFromJSON(`{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1}}`))
+ if diags.HasError() || !omitted.RefreshOnProfile.IsNull() {
+ t.Fatalf("omitted refresh_on_profile_update = %v, diagnostics = %v", omitted.RefreshOnProfile, diags)
+ }
+
state, diags := flattenBrowserPool(*browserPoolFromJSON(`{
"id":"pool-1",
"extension_ids":[],
@@ -415,7 +424,8 @@ func TestFlattenBrowserPoolLaunchConfiguration(t *testing.T) {
"proxy_id":"proxy-1",
"headless":true,
"kiosk_mode":false,
- "stealth":true
+ "stealth":true,
+ "refresh_on_profile_update":false
}
}`))
if diags.HasError() {
@@ -433,21 +443,26 @@ func TestFlattenBrowserPoolLaunchConfiguration(t *testing.T) {
if !state.Stealth.ValueBool() {
t.Fatal("stealth = false, want true")
}
+ if state.RefreshOnProfile.IsNull() || state.RefreshOnProfile.ValueBool() {
+ t.Fatalf("refresh_on_profile_update = %v, want known false", state.RefreshOnProfile)
+ }
}
func TestFlattenBrowserPoolRejectsInvalidLaunchConfiguration(t *testing.T) {
t.Parallel()
tests := map[string]string{
- "empty proxy ID": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"proxy_id":""}}`,
- "null proxy ID": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"proxy_id":null}}`,
- "non-string proxy": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"proxy_id":1}}`,
- "null headless": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"headless":null}}`,
- "non-bool headless": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"headless":"true"}}`,
- "null kiosk": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"kiosk_mode":null}}`,
- "non-bool kiosk": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"kiosk_mode":1}}`,
- "null stealth": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"stealth":null}}`,
- "non-bool stealth": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"stealth":{}}}`,
+ "empty proxy ID": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"proxy_id":""}}`,
+ "null proxy ID": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"proxy_id":null}}`,
+ "non-string proxy": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"proxy_id":1}}`,
+ "null headless": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"headless":null}}`,
+ "non-bool headless": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"headless":"true"}}`,
+ "null kiosk": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"kiosk_mode":null}}`,
+ "non-bool kiosk": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"kiosk_mode":1}}`,
+ "null stealth": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"stealth":null}}`,
+ "non-bool stealth": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"stealth":{}}}`,
+ "null profile refresh": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"refresh_on_profile_update":null}}`,
+ "non-bool profile refresh": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"refresh_on_profile_update":"false"}}`,
}
for name, body := range tests {
@@ -649,38 +664,40 @@ func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value {
}}
return tftypes.NewValue(
tftypes.Object{AttributeTypes: map[string]tftypes.Type{
- "id": tftypes.String,
- "name": tftypes.String,
- "project_id": tftypes.String,
- "size": tftypes.Number,
- "profile_id": tftypes.String,
- "extension_ids": tftypes.List{ElementType: tftypes.String},
- "proxy_id": tftypes.String,
- "headless": tftypes.Bool,
- "kiosk_mode": tftypes.Bool,
- "stealth": tftypes.Bool,
- "start_url": tftypes.String,
- "timeout_seconds": tftypes.Number,
- "fill_rate_per_minute": tftypes.Number,
- "viewport": viewportType,
- "chrome_policy": tftypes.String,
+ "id": tftypes.String,
+ "name": tftypes.String,
+ "project_id": tftypes.String,
+ "size": tftypes.Number,
+ "profile_id": tftypes.String,
+ "refresh_on_profile_update": tftypes.Bool,
+ "extension_ids": tftypes.List{ElementType: tftypes.String},
+ "proxy_id": tftypes.String,
+ "headless": tftypes.Bool,
+ "kiosk_mode": tftypes.Bool,
+ "stealth": tftypes.Bool,
+ "start_url": tftypes.String,
+ "timeout_seconds": tftypes.Number,
+ "fill_rate_per_minute": tftypes.Number,
+ "viewport": viewportType,
+ "chrome_policy": tftypes.String,
}},
map[string]tftypes.Value{
- "id": id,
- "name": name,
- "project_id": projectID,
- "size": tftypes.NewValue(tftypes.Number, nil),
- "profile_id": tftypes.NewValue(tftypes.String, nil),
- "extension_ids": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, nil),
- "proxy_id": tftypes.NewValue(tftypes.String, nil),
- "headless": tftypes.NewValue(tftypes.Bool, nil),
- "kiosk_mode": tftypes.NewValue(tftypes.Bool, nil),
- "stealth": tftypes.NewValue(tftypes.Bool, nil),
- "start_url": tftypes.NewValue(tftypes.String, nil),
- "timeout_seconds": tftypes.NewValue(tftypes.Number, nil),
- "fill_rate_per_minute": tftypes.NewValue(tftypes.Number, nil),
- "viewport": tftypes.NewValue(viewportType, nil),
- "chrome_policy": tftypes.NewValue(tftypes.String, nil),
+ "id": id,
+ "name": name,
+ "project_id": projectID,
+ "size": tftypes.NewValue(tftypes.Number, nil),
+ "profile_id": tftypes.NewValue(tftypes.String, nil),
+ "refresh_on_profile_update": tftypes.NewValue(tftypes.Bool, nil),
+ "extension_ids": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, nil),
+ "proxy_id": tftypes.NewValue(tftypes.String, nil),
+ "headless": tftypes.NewValue(tftypes.Bool, nil),
+ "kiosk_mode": tftypes.NewValue(tftypes.Bool, nil),
+ "stealth": tftypes.NewValue(tftypes.Bool, nil),
+ "start_url": tftypes.NewValue(tftypes.String, nil),
+ "timeout_seconds": tftypes.NewValue(tftypes.Number, nil),
+ "fill_rate_per_minute": tftypes.NewValue(tftypes.Number, nil),
+ "viewport": tftypes.NewValue(viewportType, nil),
+ "chrome_policy": tftypes.NewValue(tftypes.String, nil),
},
)
}
From b3a94b8e0043fbd76102ebd387fb099ec1f5c7bb Mon Sep 17 00:00:00 2001
From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com>
Date: Fri, 31 Jul 2026 17:55:06 -0400
Subject: [PATCH 17/18] test browser pool refresh schema semantics
Assert the data source exposes refresh_on_profile_update as a computed-only bool so schema drift is caught by unit tests.
---
internal/datasources/browserpool/datasource_test.go | 1 +
1 file changed, 1 insertion(+)
diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go
index 045a06b..b04a331 100644
--- a/internal/datasources/browserpool/datasource_test.go
+++ b/internal/datasources/browserpool/datasource_test.go
@@ -85,6 +85,7 @@ func TestDataSourceSchemaSemantics(t *testing.T) {
assertAttributeMode(t, resp.Schema, "project_id", true, false)
assertAttributeMode(t, resp.Schema, "size", false, true)
assertAttributeMode(t, resp.Schema, "profile_id", false, true)
+ assertAttributeMode(t, resp.Schema, "refresh_on_profile_update", false, true)
assertAttributeMode(t, resp.Schema, "extension_ids", false, true)
assertAttributeMode(t, resp.Schema, "proxy_id", false, true)
assertAttributeMode(t, resp.Schema, "headless", false, true)
From 09b0ad5cf28724995ed8fbc63fb93ea82f29fc43 Mon Sep 17 00:00:00 2001
From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com>
Date: Mon, 3 Aug 2026 13:57:05 -0400
Subject: [PATCH 18/18] Preserve profile refresh state in plans
---
internal/resources/browserpool/schema.go | 4 +++
internal/resources/browserpool/schema_test.go | 30 +++++++++++++++++++
2 files changed, 34 insertions(+)
diff --git a/internal/resources/browserpool/schema.go b/internal/resources/browserpool/schema.go
index 87eaed3..68b8d9b 100644
--- a/internal/resources/browserpool/schema.go
+++ b/internal/resources/browserpool/schema.go
@@ -5,6 +5,7 @@ import (
"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/boolplanmodifier"
"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"
@@ -60,6 +61,9 @@ func BrowserPoolSchema() rschema.Schema {
Optional: true,
Computed: true,
MarkdownDescription: "When true, idle browsers are refreshed when the pool's profile is updated. Requires `profile_id` to be set.",
+ PlanModifiers: []planmodifier.Bool{
+ boolplanmodifier.UseStateForUnknown(),
+ },
Validators: []validator.Bool{
refreshOnProfileUpdateValidator{},
},
diff --git a/internal/resources/browserpool/schema_test.go b/internal/resources/browserpool/schema_test.go
index bb4c774..bc4ff52 100644
--- a/internal/resources/browserpool/schema_test.go
+++ b/internal/resources/browserpool/schema_test.go
@@ -105,6 +105,36 @@ func TestSchemaRequiredComputedOptionalSemantics(t *testing.T) {
}
}
+func TestSchemaRefreshOnProfileUpdatePreservesStateDuringUnrelatedUpdate(t *testing.T) {
+ attr := boolAttribute(t, BrowserPoolSchema(), "refresh_on_profile_update")
+ planned := runBoolPlanModifiers(t, attr,
+ types.BoolValue(false), types.BoolUnknown(), types.BoolNull())
+
+ if !planned.Equal(types.BoolValue(false)) {
+ t.Fatalf("unset refresh_on_profile_update should keep the state value, got %v", 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)
+ req.PlanValue = resp.PlanValue
+ }
+ return req.PlanValue
+}
+
func TestSchemaProjectIDSemantics(t *testing.T) {
s := BrowserPoolSchema()