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/docs/data-sources/browser_pool.md b/docs/data-sources/browser_pool.md
new file mode 100644
index 0000000..f5bc60b
--- /dev/null
+++ b/docs/data-sources/browser_pool.md
@@ -0,0 +1,47 @@
+---
+# 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
+
+- `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.
+- `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.
+- `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/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/datasources/browserpool/datasource.go b/internal/datasources/browserpool/datasource.go
new file mode 100644
index 0000000..0d04640
--- /dev/null
+++ b/internal/datasources/browserpool/datasource.go
@@ -0,0 +1,513 @@
+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"
+ "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"
+)
+
+var (
+ _ datasource.DataSource = (*browserPoolDataSource)(nil)
+ _ datasource.DataSourceWithConfigure = (*browserPoolDataSource)(nil)
+)
+
+const (
+ minBrowserPoolTimeoutSeconds = 10
+ maxBrowserPoolTimeoutSeconds = 259200
+ minBrowserPoolFillRate = 0
+ minBrowserPoolViewportValue = 1
+)
+
+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"`
+ 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"`
+ 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"`
+ Viewport types.Object `tfsdk:"viewport"`
+ ChromePolicy types.String `tfsdk:"chrome_policy"`
+}
+
+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.",
+ },
+ "profile_id": dschema.StringAttribute{
+ 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,
+ 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.",
+ },
+ "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.",
+ },
+ "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."},
+ },
+ },
+ "chrome_policy": dschema.StringAttribute{
+ Computed: true,
+ MarkdownDescription: "Normalized JSON object of Chrome enterprise policy overrides, if configured.",
+ },
+ },
+ }
+}
+
+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
+ }
+
+ config := pool.BrowserPoolConfig
+ return browserPoolModel{
+ ID: types.StringValue(pool.ID),
+ 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),
+ 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),
+ 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
+}
+
+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 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 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 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 != "" {
+ 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
+ }
+ 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_acc_test.go b/internal/datasources/browserpool/datasource_acc_test.go
new file mode 100644
index 0000000..67806c3
--- /dev/null
+++ b/internal/datasources/browserpool/datasource_acc_test.go
@@ -0,0 +1,149 @@
+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
+ refresh_on_profile_update = 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, "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"),
+ 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"),
+ )
+}
diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go
new file mode 100644
index 0000000..b04a331
--- /dev/null
+++ b/internal/datasources/browserpool/datasource_test.go
@@ -0,0 +1,732 @@
+package browserpool
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strconv"
+ "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"
+ 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", "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)
+ }
+ }
+ 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 TestDataSourceSchemaSemantics(t *testing.T) {
+ t.Parallel()
+
+ ds := NewDataSource()
+ var resp datasource.SchemaResponse
+ ds.Schema(context.Background(), datasource.SchemaRequest{}, &resp)
+
+ 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)
+ 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)
+ 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)
+ 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)
+ }
+ assertAttributeMode(t, resp.Schema, "chrome_policy", false, true)
+
+ projectID := resp.Schema.Attributes["project_id"].(dschema.StringAttribute)
+ 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 assertAttributeMode(t *testing.T, schema dschema.Schema, name string, optional, computed bool) {
+ t.Helper()
+ 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)
+ }
+ 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 {
+ 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()
+
+ 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 browserPoolFromJSON(`{
+ "id":"pool-1",
+ "name":"Pool",
+ "extension_ids":[],
+ "browser_pool_config":{
+ "size":2,
+ "proxy_id":"proxy-1",
+ "headless":true,
+ "kiosk_mode":false,
+ "stealth":true,
+ "refresh_on_profile_update":true,
+ "start_url":"chrome://newtab",
+ "timeout_seconds":10,
+ "fill_rate_per_minute":0,
+ "viewport":{"width":1280,"height":800,"refresh_rate":60},
+ "chrome_policy":{"RestoreOnStartup":4,"HomepageLocation":"https://example.com?x=1&y=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)
+ }
+ if !state.ProfileID.IsNull() {
+ 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)
+ }
+ 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)
+ }
+ 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) {
+ 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 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()
+
+ 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) {
+ 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) {
+ 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":[],
+ "browser_pool_config":{
+ "size":1,
+ "proxy_id":"proxy-1",
+ "headless":true,
+ "kiosk_mode":false,
+ "stealth":true,
+ "refresh_on_profile_update":false
+ }
+ }`))
+ 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")
+ }
+ 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":{}}}`,
+ "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 {
+ 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) {
+ 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) {
+ 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 + `,"extension_ids":[],"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 {
+ 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,
+ "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),
+ "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),
+ },
+ )
+}
+
+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() {
+ 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])
+ }
+ }
+}
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)
}
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..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"
@@ -56,6 +57,17 @@ 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.",
+ PlanModifiers: []planmodifier.Bool{
+ boolplanmodifier.UseStateForUnknown(),
+ },
+ 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..bc4ff52 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
})
@@ -101,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()
@@ -216,6 +250,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 {