diff --git a/docs/data-sources/browser_pool.md b/docs/data-sources/browser_pool.md new file mode 100644 index 0000000..9b92a3c --- /dev/null +++ b/docs/data-sources/browser_pool.md @@ -0,0 +1,26 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "kernel_browser_pool Data Source - Kernel" +subcategory: "" +description: |- + Lookup durable Kernel browser pool configuration. +--- + +# kernel_browser_pool (Data Source) + +Lookup durable Kernel browser pool configuration. + + + + +## Schema + +### Optional + +- `id` (String) Browser pool ID. +- `name` (String) Browser pool name for exact lookup. +- `project_id` (String) Project to look the browser pool up in. Defaults to the provider `project_id`; when neither is set, the API key's project binding determines the project. + +### Read-Only + +- `size` (Number) Number of browsers maintained in the pool. diff --git a/internal/datasources/browserpool/datasource.go b/internal/datasources/browserpool/datasource.go new file mode 100644 index 0000000..fa044d0 --- /dev/null +++ b/internal/datasources/browserpool/datasource.go @@ -0,0 +1,216 @@ +package browserpool + +import ( + "context" + "encoding/json" + "strconv" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + dschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/terraform-provider-kernel/internal/datasources" + "github.com/kernel/terraform-provider-kernel/internal/projectscope" +) + +var ( + _ datasource.DataSource = (*browserPoolDataSource)(nil) + _ datasource.DataSourceWithConfigure = (*browserPoolDataSource)(nil) +) + +type browserPoolClient interface { + DefaultProjectID() string + GetBrowserPool(context.Context, string, string) (*kernel.BrowserPool, error) +} + +type browserPoolDataSource struct { + client browserPoolClient +} + +type browserPoolModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + ProjectID types.String `tfsdk:"project_id"` + Size types.Int64 `tfsdk:"size"` +} + +func NewDataSource() datasource.DataSource { + return &browserPoolDataSource{} +} + +func newDataSourceWithClient(client browserPoolClient) *browserPoolDataSource { + return &browserPoolDataSource{client: client} +} + +func (d *browserPoolDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_browser_pool" +} + +func (d *browserPoolDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = dschema.Schema{ + MarkdownDescription: "Lookup durable Kernel browser pool configuration.", + Attributes: map[string]dschema.Attribute{ + "id": dschema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "Browser pool ID.", + }, + "name": dschema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "Browser pool name for exact lookup.", + }, + "project_id": dschema.StringAttribute{ + Optional: true, + MarkdownDescription: "Project to look the browser pool up in. Defaults to the provider `project_id`; when neither is set, the API key's project binding determines the project.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "size": dschema.Int64Attribute{ + Computed: true, + MarkdownDescription: "Number of browsers maintained in the pool.", + }, + }, + } +} + +func (d *browserPoolDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(browserPoolClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Kernel Client Type", + "Expected provider data to implement the browser pool data source durable client contract.", + ) + return + } + d.client = client +} + +func (d *browserPoolDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config browserPoolModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + state, diags := d.read(ctx, config) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +func (d *browserPoolDataSource) read(ctx context.Context, config browserPoolModel) (browserPoolModel, diag.Diagnostics) { + var diags diag.Diagnostics + if d.client == nil { + diags.AddError("Missing Kernel Client", "The browser pool data source was not configured with a Kernel client.") + return browserPoolModel{}, diags + } + + selector, selectorDiags := datasources.ResolveIDNameSelector("Browser Pool", "kernel_browser_pool", config.ID, config.Name) + diags.Append(selectorDiags...) + if diags.HasError() { + return browserPoolModel{}, diags + } + + projectID := projectscope.ResolveDataSource(&diags, config.ProjectID, d.client.DefaultProjectID()) + if diags.HasError() { + return browserPoolModel{}, diags + } + + var idOrName string + switch { + case selector.HasID: + idOrName = config.ID.ValueString() + case selector.HasName: + idOrName = config.Name.ValueString() + default: + diags.AddError("Missing Browser Pool Selector", "Configure id or name for kernel_browser_pool.") + return browserPoolModel{}, diags + } + + pool, err := d.client.GetBrowserPool(ctx, projectID, idOrName) + if err != nil { + projectscope.AddError(&diags, "Read Kernel Browser Pool", projectID, err) + return browserPoolModel{}, diags + } + if pool == nil { + diags.AddError("Read Kernel Browser Pool", "Kernel returned an empty browser pool response.") + return browserPoolModel{}, diags + } + + state, flattenDiags := flattenBrowserPool(*pool) + diags.Append(flattenDiags...) + if diags.HasError() { + return browserPoolModel{}, diags + } + if selector.HasID && state.ID.ValueString() != config.ID.ValueString() { + diags.AddError( + "Browser Pool ID Mismatch", + "Kernel returned browser pool "+strconv.Quote(state.ID.ValueString())+" for id selector "+strconv.Quote(config.ID.ValueString())+".", + ) + return browserPoolModel{}, diags + } + if selector.HasName && (state.Name.IsNull() || state.Name.ValueString() != config.Name.ValueString()) { + diags.AddError( + "Browser Pool Name Mismatch", + "Kernel returned a browser pool whose name does not match exact selector "+strconv.Quote(config.Name.ValueString())+".", + ) + return browserPoolModel{}, diags + } + + state.ProjectID = config.ProjectID + return state, diags +} + +func flattenBrowserPool(pool kernel.BrowserPool) (browserPoolModel, diag.Diagnostics) { + var diags diag.Diagnostics + if !datasources.ValidResponseString(pool.JSON.ID.Raw(), pool.JSON.ID.Valid(), pool.ID) { + datasources.AddInvalidResponseField(&diags, "Browser Pool", "id") + } + if !validResponseInt64(pool.BrowserPoolConfig.JSON.Size.Raw(), pool.BrowserPoolConfig.JSON.Size.Valid(), pool.BrowserPoolConfig.Size) || pool.BrowserPoolConfig.Size < 1 { + datasources.AddInvalidResponseField(&diags, "Browser Pool", "browser_pool_config.size") + } + + name := types.StringNull() + switch { + case datasources.FieldPresent(pool.JSON.Name.Raw()): + if !datasources.ValidResponseString(pool.JSON.Name.Raw(), pool.JSON.Name.Valid(), pool.Name) { + datasources.AddInvalidResponseField(&diags, "Browser Pool", "name") + } else { + name = types.StringValue(pool.Name) + } + case datasources.FieldPresent(pool.BrowserPoolConfig.JSON.Name.Raw()): + if !datasources.ValidResponseString(pool.BrowserPoolConfig.JSON.Name.Raw(), pool.BrowserPoolConfig.JSON.Name.Valid(), pool.BrowserPoolConfig.Name) { + datasources.AddInvalidResponseField(&diags, "Browser Pool", "browser_pool_config.name") + } else { + name = types.StringValue(pool.BrowserPoolConfig.Name) + } + } + if diags.HasError() { + return browserPoolModel{}, diags + } + + return browserPoolModel{ + ID: types.StringValue(pool.ID), + Name: name, + Size: types.Int64Value(pool.BrowserPoolConfig.Size), + }, diags +} + +func validResponseInt64(raw string, valid bool, value int64) bool { + if !datasources.FieldPresent(raw) || !valid { + return false + } + var decoded int64 + return json.Unmarshal([]byte(raw), &decoded) == nil && decoded == value +} diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go new file mode 100644 index 0000000..d276787 --- /dev/null +++ b/internal/datasources/browserpool/datasource_test.go @@ -0,0 +1,322 @@ +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"} { + 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) + + 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() + attribute, ok := schema.Attributes[name] + if !ok { + t.Fatalf("schema missing %s", name) + } + if attribute.IsOptional() != optional || attribute.IsComputed() != computed || attribute.IsRequired() { + t.Fatalf("%s has unexpected schema mode: %#v", name, attribute) + } +} + +func validateProjectID(validators []validator.String, value string) diag.Diagnostics { + var diags diag.Diagnostics + for _, candidate := range validators { + 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 browserPoolForTest("pool-1", "Pool", 2), nil + }, + }) + var schema datasource.SchemaResponse + ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema) + req := datasource.ReadRequest{Config: tfsdk.Config{ + Schema: schema.Schema, + Raw: browserPoolConfigValue( + tftypes.NewValue(tftypes.String, nil), + tftypes.NewValue(tftypes.String, "Pool"), + tftypes.NewValue(tftypes.String, nil), + ), + }} + resp := datasource.ReadResponse{State: tfsdk.State{Schema: schema.Schema}} + + ds.Read(context.Background(), req, &resp) + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + + var state browserPoolModel + resp.Diagnostics.Append(resp.State.Get(context.Background(), &state)...) + if resp.Diagnostics.HasError() { + t.Fatalf("read state: %v", resp.Diagnostics) + } + if state.ID.ValueString() != "pool-1" || state.Name.ValueString() != "Pool" || state.Size.ValueInt64() != 2 { + t.Fatalf("state = %#v", state) + } +} + +func TestReadBrowserPoolAllowsUnnamedPoolByID(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeBrowserPoolClient{ + get: func(context.Context, string, string) (*kernel.BrowserPool, error) { + return browserPoolForTest("pool-1", "", 1), nil + }, + }) + state, diags := ds.read(context.Background(), browserPoolModel{ID: types.StringValue("pool-1")}) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if !state.Name.IsNull() { + t.Fatalf("name = %v, want null", state.Name) + } +} + +func TestReadBrowserPoolRejectsInvalidInputsAndResponses(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + client browserPoolClient + config browserPoolModel + }{ + "missing client": {config: browserPoolModel{ID: types.StringValue("pool-1")}}, + "missing selector": {client: fakeBrowserPoolClient{}}, + "conflicting selectors": { + client: fakeBrowserPoolClient{}, + config: browserPoolModel{ID: types.StringValue("pool-1"), Name: types.StringValue("Pool")}, + }, + "unknown project": { + client: fakeBrowserPoolClient{}, + config: browserPoolModel{ID: types.StringValue("pool-1"), ProjectID: types.StringUnknown()}, + }, + "API error": { + client: fakeBrowserPoolClient{get: func(context.Context, string, string) (*kernel.BrowserPool, error) { + return nil, errors.New("connection reset") + }}, + config: browserPoolModel{ID: types.StringValue("pool-1")}, + }, + "empty response": { + client: fakeBrowserPoolClient{get: func(context.Context, string, string) (*kernel.BrowserPool, error) { return nil, nil }}, + config: browserPoolModel{ID: types.StringValue("pool-1")}, + }, + "invalid response": { + client: fakeBrowserPoolClient{get: func(context.Context, string, string) (*kernel.BrowserPool, error) { + return browserPoolFromJSON(`{"id":"pool-1","browser_pool_config":{"size":"2"}}`), nil + }}, + config: browserPoolModel{ID: types.StringValue("pool-1")}, + }, + "ID mismatch": { + client: fakeBrowserPoolClient{get: func(context.Context, string, string) (*kernel.BrowserPool, error) { + return browserPoolForTest("pool-other", "Pool", 1), nil + }}, + config: browserPoolModel{ID: types.StringValue("pool-1")}, + }, + "name mismatch": { + client: fakeBrowserPoolClient{get: func(context.Context, string, string) (*kernel.BrowserPool, error) { + return browserPoolForTest("pool-1", "Other", 1), nil + }}, + config: browserPoolModel{Name: types.StringValue("Pool")}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + ds := newDataSourceWithClient(test.client) + _, diags := ds.read(context.Background(), test.config) + if !diags.HasError() { + t.Fatal("expected diagnostics") + } + }) + } +} + +func browserPoolForTest(id, name string, size int64) *kernel.BrowserPool { + nameJSON := "null" + configName := "" + if name != "" { + nameJSON = strconv.Quote(name) + configName = `,"name":` + strconv.Quote(name) + } + return browserPoolFromJSON(`{"id":` + strconv.Quote(id) + `,"name":` + nameJSON + `,"browser_pool_config":{"size":` + strconv.FormatInt(size, 10) + configName + `}}`) +} + +func browserPoolFromJSON(body string) *kernel.BrowserPool { + var pool kernel.BrowserPool + if err := json.Unmarshal([]byte(body), &pool); err != nil { + panic(err) + } + return &pool +} + +func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value { + return tftypes.NewValue( + tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "name": tftypes.String, + "project_id": tftypes.String, + "size": tftypes.Number, + }}, + map[string]tftypes.Value{ + "id": id, + "name": name, + "project_id": projectID, + "size": tftypes.NewValue(tftypes.Number, nil), + }, + ) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 5e3a9ee..2e647a3 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/resource" + browserpooldatasource "github.com/kernel/terraform-provider-kernel/internal/datasources/browserpool" "github.com/kernel/terraform-provider-kernel/internal/datasources/extension" "github.com/kernel/terraform-provider-kernel/internal/datasources/profile" projectdatasource "github.com/kernel/terraform-provider-kernel/internal/datasources/project" @@ -88,6 +89,7 @@ func (p *kernelProvider) Resources(ctx context.Context) []func() resource.Resour func (p *kernelProvider) DataSources(ctx context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{ + browserpooldatasource.NewDataSource, projectdatasource.NewDataSource, profile.NewDataSource, proxy.NewDataSource, diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index a3fdd0d..f1905f7 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -62,8 +62,8 @@ func TestProviderRegistersDataSources(t *testing.T) { p := provider.New("test")() dataSources := p.DataSources(context.Background()) - if len(dataSources) != 4 { - t.Fatalf("DataSources length = %d, want 4", len(dataSources)) + if len(dataSources) != 5 { + t.Fatalf("DataSources length = %d, want 5", len(dataSources)) } got := make(map[string]bool, len(dataSources)) @@ -77,7 +77,7 @@ func TestProviderRegistersDataSources(t *testing.T) { got[resp.TypeName] = true } - for _, want := range []string{"kernel_project", "kernel_profile", "kernel_proxy", "kernel_extension"} { + for _, want := range []string{"kernel_browser_pool", "kernel_project", "kernel_profile", "kernel_proxy", "kernel_extension"} { if !got[want] { t.Fatalf("missing data source %s; got %v", want, got) }