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..6cd7157 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) } @@ -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() { @@ -195,7 +198,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 +238,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 +530,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), }, ) }