diff --git a/docs/data-sources/browser_pool.md b/docs/data-sources/browser_pool.md index 9b92a3c..1e7b1e9 100644 --- a/docs/data-sources/browser_pool.md +++ b/docs/data-sources/browser_pool.md @@ -23,4 +23,6 @@ Lookup durable Kernel browser pool configuration. ### Read-Only +- `extension_ids` (List of String) Resolved extension IDs attached to the pool, in load order. +- `profile_id` (String) Resolved profile ID attached to the pool, if any. - `size` (Number) Number of browsers maintained in the pool. diff --git a/internal/datasources/browserpool/datasource.go b/internal/datasources/browserpool/datasource.go index fa044d0..aa9fa0b 100644 --- a/internal/datasources/browserpool/datasource.go +++ b/internal/datasources/browserpool/datasource.go @@ -3,15 +3,18 @@ package browserpool import ( "context" "encoding/json" + "fmt" "strconv" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/datasource" dschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/shared" "github.com/kernel/terraform-provider-kernel/internal/datasources" "github.com/kernel/terraform-provider-kernel/internal/projectscope" ) @@ -31,10 +34,12 @@ type browserPoolDataSource struct { } type browserPoolModel struct { - ID types.String `tfsdk:"id"` - Name types.String `tfsdk:"name"` - ProjectID types.String `tfsdk:"project_id"` - Size types.Int64 `tfsdk:"size"` + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + ProjectID types.String `tfsdk:"project_id"` + Size types.Int64 `tfsdk:"size"` + ProfileID types.String `tfsdk:"profile_id"` + ExtensionIDs types.List `tfsdk:"extension_ids"` } func NewDataSource() datasource.DataSource { @@ -74,6 +79,15 @@ func (d *browserPoolDataSource) Schema(_ context.Context, _ datasource.SchemaReq Computed: true, MarkdownDescription: "Number of browsers maintained in the pool.", }, + "profile_id": dschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Resolved profile ID attached to the pool, if any.", + }, + "extension_ids": dschema.ListAttribute{ + Computed: true, + ElementType: types.StringType, + MarkdownDescription: "Resolved extension IDs attached to the pool, in load order.", + }, }, } } @@ -201,12 +215,87 @@ func flattenBrowserPool(pool kernel.BrowserPool) (browserPoolModel, diag.Diagnos } return browserPoolModel{ - ID: types.StringValue(pool.ID), - Name: name, - Size: types.Int64Value(pool.BrowserPoolConfig.Size), + ID: types.StringValue(pool.ID), + Name: name, + Size: types.Int64Value(pool.BrowserPoolConfig.Size), + ProfileID: flattenResolvedProfileID(pool, &diags), + ExtensionIDs: flattenResolvedExtensionIDs(pool, &diags), }, diags } +func flattenResolvedProfileID(pool kernel.BrowserPool, diags *diag.Diagnostics) types.String { + raw := pool.JSON.ProfileID.Raw() + if raw != "" { + if !datasources.FieldPresent(raw) { + datasources.AddInvalidResponseField(diags, "Browser Pool", "profile_id") + return types.StringNull() + } + if !datasources.ValidResponseString(raw, pool.JSON.ProfileID.Valid(), pool.ProfileID) { + datasources.AddInvalidResponseField(diags, "Browser Pool", "profile_id") + return types.StringNull() + } + return types.StringValue(pool.ProfileID) + } + + profile := pool.BrowserPoolConfig.Profile + if !datasources.FieldPresent(pool.BrowserPoolConfig.JSON.Profile.Raw()) { + return types.StringNull() + } + if !datasources.ValidResponseString(profile.JSON.ID.Raw(), profile.JSON.ID.Valid(), profile.ID) { + datasources.AddInvalidResponseField(diags, "Browser Pool", "browser_pool_config.profile.id") + return types.StringNull() + } + return types.StringValue(profile.ID) +} + +func flattenResolvedExtensionIDs(pool kernel.BrowserPool, diags *diag.Diagnostics) types.List { + raw := pool.JSON.ExtensionIDs.Raw() + if raw != "" { + return flattenStringList("extension_ids", raw, pool.JSON.ExtensionIDs.Valid(), pool.ExtensionIDs, diags) + } + + config := pool.BrowserPoolConfig + if !datasources.FieldPresent(config.JSON.Extensions.Raw()) { + return types.ListValueMust(types.StringType, nil) + } + return flattenExtensionIDs(config.JSON.Extensions.Valid(), config.Extensions, diags) +} + +func flattenStringList(field, raw string, valid bool, values []string, diags *diag.Diagnostics) types.List { + var decoded []string + if !datasources.FieldPresent(raw) || !valid || json.Unmarshal([]byte(raw), &decoded) != nil || len(decoded) != len(values) { + datasources.AddInvalidResponseField(diags, "Browser Pool", field) + return types.ListNull(types.StringType) + } + + elements := make([]attr.Value, 0, len(values)) + for index, value := range values { + if value == "" || decoded[index] != value { + datasources.AddInvalidResponseField(diags, "Browser Pool", fmt.Sprintf("%s[%d]", field, index)) + return types.ListNull(types.StringType) + } + elements = append(elements, types.StringValue(value)) + } + return types.ListValueMust(types.StringType, elements) +} + +func flattenExtensionIDs(valid bool, extensions []shared.BrowserExtension, diags *diag.Diagnostics) types.List { + if !valid { + datasources.AddInvalidResponseField(diags, "Browser Pool", "browser_pool_config.extensions") + return types.ListNull(types.StringType) + } + + elements := make([]attr.Value, 0, len(extensions)) + for index, extension := range extensions { + if !datasources.ValidResponseString(extension.JSON.ID.Raw(), extension.JSON.ID.Valid(), extension.ID) { + datasources.AddInvalidResponseField(diags, "Browser Pool", fmt.Sprintf("browser_pool_config.extensions[%d].id", index)) + return types.ListNull(types.StringType) + } + elements = append(elements, types.StringValue(extension.ID)) + } + return types.ListValueMust(types.StringType, elements) +} + func validResponseInt64(raw string, valid bool, value int64) bool { if !datasources.FieldPresent(raw) || !valid { return false diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go index d276787..b2b82d8 100644 --- a/internal/datasources/browserpool/datasource_test.go +++ b/internal/datasources/browserpool/datasource_test.go @@ -48,7 +48,7 @@ func TestDataSourceMetadataSchemaAndConfigure(t *testing.T) { var schema datasource.SchemaResponse ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema) - for _, name := range []string{"id", "name", "project_id", "size"} { + for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "extension_ids"} { if _, ok := schema.Schema.Attributes[name]; !ok { t.Fatalf("schema missing %s", name) } @@ -84,6 +84,8 @@ func TestDataSourceSchemaSemantics(t *testing.T) { assertAttributeMode(t, resp.Schema, "name", true, true) assertAttributeMode(t, resp.Schema, "project_id", true, false) assertAttributeMode(t, resp.Schema, "size", false, true) + assertAttributeMode(t, resp.Schema, "profile_id", false, true) + assertAttributeMode(t, resp.Schema, "extension_ids", false, true) projectID := resp.Schema.Attributes["project_id"].(dschema.StringAttribute) if !validateProjectID(projectID.Validators, "").HasError() { @@ -208,6 +210,96 @@ func TestReadSetsTerraformState(t *testing.T) { if state.ID.ValueString() != "pool-1" || state.Name.ValueString() != "Pool" || state.Size.ValueInt64() != 2 { t.Fatalf("state = %#v", state) } + if !state.ProfileID.IsNull() { + t.Fatalf("profile_id = %v, want null", state.ProfileID) + } + assertBrowserPoolStringList(t, state.ExtensionIDs, nil) +} + +func TestFlattenBrowserPoolResolvedReferences(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + body string + wantProfileID string + wantProfileNull bool + wantExtensionIDs []string + }{ + "authoritative fields": { + body: `{ + "id":"pool-1", + "profile_id":"profile-resolved", + "extension_ids":["extension-b","extension-a"], + "browser_pool_config":{ + "size":1, + "profile":{"name":"profile-selector"}, + "extensions":[{"name":"extension-selector-b"},{"name":"extension-selector-a"}] + } + }`, + wantProfileID: "profile-resolved", + wantExtensionIDs: []string{"extension-b", "extension-a"}, + }, + "legacy ID selectors": { + body: `{ + "id":"pool-1", + "browser_pool_config":{ + "size":1, + "profile":{"id":"profile-legacy"}, + "extensions":[{"id":"extension-b"},{"id":"extension-a"}] + } + }`, + wantProfileID: "profile-legacy", + wantExtensionIDs: []string{"extension-b", "extension-a"}, + }, + "no references": { + body: `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1}}`, + wantProfileNull: true, + wantExtensionIDs: nil, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + state, diags := flattenBrowserPool(*browserPoolFromJSON(test.body)) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if test.wantProfileNull { + if !state.ProfileID.IsNull() { + t.Fatalf("profile_id = %v, want null", state.ProfileID) + } + } else if state.ProfileID.ValueString() != test.wantProfileID { + t.Fatalf("profile_id = %q, want %q", state.ProfileID.ValueString(), test.wantProfileID) + } + assertBrowserPoolStringList(t, state.ExtensionIDs, test.wantExtensionIDs) + }) + } +} + +func TestFlattenBrowserPoolRejectsInvalidResolvedReferences(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + "null authoritative extensions": `{"id":"pool-1","extension_ids":null,"browser_pool_config":{"size":1}}`, + "non-list authoritative extensions": `{"id":"pool-1","extension_ids":{},"browser_pool_config":{"size":1}}`, + "empty authoritative extension ID": `{"id":"pool-1","extension_ids":[""],"browser_pool_config":{"size":1}}`, + "null authoritative profile ID": `{"id":"pool-1","profile_id":null,"extension_ids":[],"browser_pool_config":{"size":1}}`, + "non-string authoritative profile ID": `{"id":"pool-1","profile_id":1,"extension_ids":[],"browser_pool_config":{"size":1}}`, + "empty authoritative profile ID": `{"id":"pool-1","profile_id":"","extension_ids":[],"browser_pool_config":{"size":1}}`, + "legacy profile name only": `{"id":"pool-1","browser_pool_config":{"size":1,"profile":{"name":"profile-selector"}}}`, + "legacy extension name only": `{"id":"pool-1","browser_pool_config":{"size":1,"extensions":[{"name":"extension-selector"}]}}`, + } + + for name, body := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, diags := flattenBrowserPool(*browserPoolFromJSON(body)) + if !diags.HasError() { + t.Fatal("expected diagnostics") + } + }) + } } func TestReadBrowserPoolAllowsUnnamedPoolByID(t *testing.T) { @@ -293,7 +385,7 @@ func browserPoolForTest(id, name string, size int64) *kernel.BrowserPool { nameJSON = strconv.Quote(name) configName = `,"name":` + strconv.Quote(name) } - return browserPoolFromJSON(`{"id":` + strconv.Quote(id) + `,"name":` + nameJSON + `,"browser_pool_config":{"size":` + strconv.FormatInt(size, 10) + configName + `}}`) + return browserPoolFromJSON(`{"id":` + strconv.Quote(id) + `,"name":` + nameJSON + `,"extension_ids":[],"browser_pool_config":{"size":` + strconv.FormatInt(size, 10) + configName + `}}`) } func browserPoolFromJSON(body string) *kernel.BrowserPool { @@ -307,16 +399,37 @@ func browserPoolFromJSON(body string) *kernel.BrowserPool { func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value { return tftypes.NewValue( tftypes.Object{AttributeTypes: map[string]tftypes.Type{ - "id": tftypes.String, - "name": tftypes.String, - "project_id": tftypes.String, - "size": tftypes.Number, + "id": tftypes.String, + "name": tftypes.String, + "project_id": tftypes.String, + "size": tftypes.Number, + "profile_id": tftypes.String, + "extension_ids": tftypes.List{ElementType: tftypes.String}, }}, map[string]tftypes.Value{ - "id": id, - "name": name, - "project_id": projectID, - "size": tftypes.NewValue(tftypes.Number, nil), + "id": id, + "name": name, + "project_id": projectID, + "size": tftypes.NewValue(tftypes.Number, nil), + "profile_id": tftypes.NewValue(tftypes.String, nil), + "extension_ids": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, nil), }, ) } + +func assertBrowserPoolStringList(t *testing.T, got types.List, want []string) { + t.Helper() + if got.IsNull() || got.IsUnknown() { + t.Fatalf("list = %v, want %v", got, want) + } + elements := got.Elements() + if len(elements) != len(want) { + t.Fatalf("list length = %d, want %d", len(elements), len(want)) + } + for index, element := range elements { + value, ok := element.(types.String) + if !ok || value.ValueString() != want[index] { + t.Fatalf("list[%d] = %v, want %q", index, element, want[index]) + } + } +}