From 74bbda87160267a27bfa439ac6df7ab95a95d40d Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:42:07 -0400 Subject: [PATCH] Add profile data source; project lookups via server-resolved Get Adds the profile data source plus the shared internal/datasources helpers and generic Page[T]/lookupNextOffset pagination. Also switches the project data source name lookup to ProjectService.Get (the API resolves id-or-name, kernel/kernel#2455) instead of a list-and-scan, and drops the now-unused ListProjectPage client method + ProjectPage page type. Pagination error-branch coverage moves to ListProfilePage. --- internal/datasources/helpers.go | 91 ++++ internal/datasources/helpers_test.go | 177 +++++++ internal/datasources/profile/datasource.go | 288 +++++++++++ .../datasources/profile/datasource_test.go | 480 ++++++++++++++++++ internal/datasources/project/datasource.go | 197 ++----- .../datasources/project/datasource_test.go | 97 ++-- internal/kernelclient/client.go | 88 +++- internal/kernelclient/client_test.go | 157 ++++-- internal/projectscope/projectscope.go | 16 + internal/projectscope/projectscope_test.go | 20 + internal/provider/provider.go | 2 + internal/provider/provider_test.go | 27 +- 12 files changed, 1326 insertions(+), 314 deletions(-) create mode 100644 internal/datasources/helpers.go create mode 100644 internal/datasources/helpers_test.go create mode 100644 internal/datasources/profile/datasource.go create mode 100644 internal/datasources/profile/datasource_test.go diff --git a/internal/datasources/helpers.go b/internal/datasources/helpers.go new file mode 100644 index 0000000..486f2f4 --- /dev/null +++ b/internal/datasources/helpers.go @@ -0,0 +1,91 @@ +package datasources + +import ( + "encoding/json" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type IDNameSelector struct { + HasID bool + HasName bool +} + +func ResolveIDNameSelector(kind, typeName string, id, name types.String) (IDNameSelector, diag.Diagnostics) { + var diags diag.Diagnostics + + if id.IsUnknown() || name.IsUnknown() { + diags.AddError( + "Unknown "+kind+" Selector", + kind+" id and name must be known before reading the data source.", + ) + return IDNameSelector{}, diags + } + if !id.IsNull() && id.ValueString() == "" { + diags.AddError( + "Empty "+kind+" ID", + kind+" id must be omitted or a non-empty string.", + ) + } + if !name.IsNull() && name.ValueString() == "" { + diags.AddError( + "Empty "+kind+" Name", + kind+" name must be omitted or a non-empty string.", + ) + } + if diags.HasError() { + return IDNameSelector{}, diags + } + + selector := IDNameSelector{ + HasID: !id.IsNull(), + HasName: !name.IsNull(), + } + if selector.HasID && selector.HasName { + diags.AddError( + "Conflicting "+kind+" Selectors", + "Configure only one of id or name for "+typeName+".", + ) + return IDNameSelector{}, diags + } + + return selector, diags +} + +func FieldPresent(raw string) bool { + return raw != "" && strings.TrimSpace(raw) != "null" +} + +func ValidResponseString(raw string, valid bool, value string) bool { + if !FieldPresent(raw) || !valid || value == "" { + return false + } + + var decoded string + if err := json.Unmarshal([]byte(raw), &decoded); err != nil { + return false + } + return decoded == value +} + +func ValidResponseTime(raw string, valid bool, value time.Time) bool { + if !FieldPresent(raw) || !valid || value.IsZero() { + return false + } + + var decoded time.Time + if err := json.Unmarshal([]byte(raw), &decoded); err != nil { + return false + } + return decoded.Equal(value) +} + +func AddInvalidResponseField(diags *diag.Diagnostics, kind, field string) { + diags.AddError( + "Invalid Kernel "+kind+" Response", + "Kernel returned a "+strings.ToLower(kind)+" with missing or invalid field "+field+".", + ) +} diff --git a/internal/datasources/helpers_test.go b/internal/datasources/helpers_test.go new file mode 100644 index 0000000..72a4a16 --- /dev/null +++ b/internal/datasources/helpers_test.go @@ -0,0 +1,177 @@ +package datasources + +import ( + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func hasDiagnosticSummary(diags diag.Diagnostics, want string) bool { + for _, diagnostic := range diags { + if diagnostic.Summary() == want { + return true + } + } + return false +} + +func TestResolveIDNameSelector(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + id types.String + name types.String + want IDNameSelector + wantErr string + }{ + "neither set falls through to the provider default": { + id: types.StringNull(), + name: types.StringNull(), + want: IDNameSelector{}, + }, + "id only": { + id: types.StringValue("proj-1"), + name: types.StringNull(), + want: IDNameSelector{HasID: true}, + }, + "name only": { + id: types.StringNull(), + name: types.StringValue("Production"), + want: IDNameSelector{HasName: true}, + }, + "both set conflict": { + id: types.StringValue("proj-1"), + name: types.StringValue("Production"), + wantErr: "Conflicting Project Selectors", + }, + "unknown id": { + id: types.StringUnknown(), + name: types.StringNull(), + wantErr: "Unknown Project Selector", + }, + "unknown name": { + id: types.StringNull(), + name: types.StringUnknown(), + wantErr: "Unknown Project Selector", + }, + "empty id": { + id: types.StringValue(""), + name: types.StringNull(), + wantErr: "Empty Project ID", + }, + "empty name": { + id: types.StringNull(), + name: types.StringValue(""), + wantErr: "Empty Project Name", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + selector, diags := ResolveIDNameSelector("Project", "kernel_project", test.id, test.name) + if test.wantErr != "" { + if !diags.HasError() { + t.Fatalf("expected diagnostics containing %q", test.wantErr) + } + if !hasDiagnosticSummary(diags.Errors(), test.wantErr) { + t.Fatalf("diagnostics = %v, want summary %q", diags, test.wantErr) + } + return + } + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if selector != test.want { + t.Fatalf("selector = %+v, want %+v", selector, test.want) + } + }) + } +} + +func TestFieldPresent(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + raw string + want bool + }{ + "absent": {raw: "", want: false}, + "json null": {raw: "null", want: false}, + "json null with padding": {raw: " null ", want: false}, + "string value": {raw: `"x"`, want: true}, + "non-null non-string raw": {raw: "123", want: true}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if got := FieldPresent(test.raw); got != test.want { + t.Fatalf("FieldPresent(%q) = %v, want %v", test.raw, got, test.want) + } + }) + } +} + +func TestValidResponseString(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + raw string + valid bool + value string + want bool + }{ + "raw matches decoded value": {raw: `"proj-1"`, valid: true, value: "proj-1", want: true}, + "absent raw": {raw: "", valid: true, value: "proj-1", want: false}, + "json null raw": {raw: "null", valid: true, value: "proj-1", want: false}, + "invalid field flag": {raw: `"proj-1"`, valid: false, value: "proj-1", want: false}, + "empty decoded value": {raw: `""`, valid: true, value: "", want: false}, + "wrong json type": {raw: "123", valid: true, value: "123", want: false}, + "raw disagrees with decoded": {raw: `"proj-1"`, valid: true, value: "proj-2", want: false}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if got := ValidResponseString(test.raw, test.valid, test.value); got != test.want { + t.Fatalf("ValidResponseString(%q, %v, %q) = %v, want %v", test.raw, test.valid, test.value, got, test.want) + } + }) + } +} + +func TestValidResponseTime(t *testing.T) { + t.Parallel() + + stamp := time.Date(2026, time.June, 5, 12, 0, 0, 0, time.UTC) + tests := map[string]struct { + raw string + valid bool + value time.Time + want bool + }{ + "raw matches decoded value": {raw: `"2026-06-05T12:00:00Z"`, valid: true, value: stamp, want: true}, + "absent raw": {raw: "", valid: true, value: stamp, want: false}, + "json null raw": {raw: "null", valid: true, value: stamp, want: false}, + "invalid field flag": {raw: `"2026-06-05T12:00:00Z"`, valid: false, value: stamp, want: false}, + "zero decoded value": {raw: `"2026-06-05T12:00:00Z"`, valid: true, value: time.Time{}, want: false}, + "non-timestamp raw": {raw: "123", valid: true, value: stamp, want: false}, + "raw disagrees with decoded": {raw: `"2026-06-05T12:00:00Z"`, valid: true, value: stamp.Add(time.Second), want: false}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if got := ValidResponseTime(test.raw, test.valid, test.value); got != test.want { + t.Fatalf("ValidResponseTime(%q, %v, %v) = %v, want %v", test.raw, test.valid, test.value, got, test.want) + } + }) + } +} diff --git a/internal/datasources/profile/datasource.go b/internal/datasources/profile/datasource.go new file mode 100644 index 0000000..dee25b7 --- /dev/null +++ b/internal/datasources/profile/datasource.go @@ -0,0 +1,288 @@ +package profile + +import ( + "context" + "time" + + "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/kernelclient" + "github.com/kernel/terraform-provider-kernel/internal/projectscope" +) + +var ( + _ datasource.DataSource = (*profileDataSource)(nil) + _ datasource.DataSourceWithConfigure = (*profileDataSource)(nil) +) + +type profileClient interface { + DefaultProjectID() string + GetProfile(context.Context, string, string) (*kernel.Profile, error) + ListProfilePage(context.Context, string, string, int64) (kernelclient.ProfilePage, error) +} + +type profileDataSource struct { + client profileClient +} + +type profileModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + ProjectID types.String `tfsdk:"project_id"` + CreatedAt types.String `tfsdk:"created_at"` +} + +func NewDataSource() datasource.DataSource { + return &profileDataSource{} +} + +func newDataSourceWithClient(client profileClient) *profileDataSource { + return &profileDataSource{client: client} +} + +func (d *profileDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_profile" +} + +func (d *profileDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = dschema.Schema{ + MarkdownDescription: "Lookup durable Kernel profile metadata.", + Attributes: map[string]dschema.Attribute{ + "id": dschema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "Profile ID.", + }, + "name": dschema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "Profile name for exact lookup.", + }, + "project_id": dschema.StringAttribute{ + Optional: true, + MarkdownDescription: "Project to look the profile 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), + }, + }, + "created_at": dschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Profile creation timestamp.", + }, + }, + } +} + +func (d *profileDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(profileClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Kernel Client Type", + "Expected provider data to implement the profile data source durable client contract.", + ) + return + } + + d.client = client +} + +func (d *profileDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config profileModel + 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 *profileDataSource) read(ctx context.Context, config profileModel) (profileModel, diag.Diagnostics) { + var diags diag.Diagnostics + if d.client == nil { + diags.AddError( + "Missing Kernel Client", + "The profile data source was not configured with a Kernel client.", + ) + return profileModel{}, diags + } + + selector, selectorDiags := datasources.ResolveIDNameSelector("Profile", "kernel_profile", config.ID, config.Name) + diags.Append(selectorDiags...) + if diags.HasError() { + return profileModel{}, diags + } + + projectID := projectscope.ResolveDataSource(&diags, config.ProjectID, d.client.DefaultProjectID()) + if diags.HasError() { + return profileModel{}, diags + } + + var state profileModel + var readDiags diag.Diagnostics + switch { + case selector.HasID: + state, readDiags = d.get(ctx, projectID, config.ID.ValueString()) + case selector.HasName: + state, readDiags = d.lookupName(ctx, projectID, config.Name.ValueString()) + default: + diags.AddError( + "Missing Profile Selector", + "Configure id or name for kernel_profile.", + ) + return profileModel{}, diags + } + + diags.Append(readDiags...) + if diags.HasError() { + return profileModel{}, diags + } + + state.ProjectID = config.ProjectID + return state, diags +} + +func (d *profileDataSource) get(ctx context.Context, projectID, id string) (profileModel, diag.Diagnostics) { + var diags diag.Diagnostics + profile, err := d.client.GetProfile(ctx, projectID, id) + if err != nil { + projectscope.AddError(&diags, "Read Kernel Profile", projectID, err) + return profileModel{}, diags + } + if profile == nil { + diags.AddError("Read Kernel Profile", "Kernel returned an empty profile response.") + return profileModel{}, diags + } + state, flattenDiags := flattenProfile(*profile) + diags.Append(flattenDiags...) + if diags.HasError() { + return profileModel{}, diags + } + if state.ID.ValueString() != id { + diags.AddError( + "Profile ID Mismatch", + "Kernel returned profile "+state.ID.ValueString()+" for id selector "+id+". Use the name selector for name lookups.", + ) + return profileModel{}, diags + } + return state, diags +} + +func (d *profileDataSource) lookupName(ctx context.Context, projectID, name string) (profileModel, diag.Diagnostics) { + var diags diag.Diagnostics + + profile, count := d.findProfilesByName(ctx, projectID, name, &diags) + if diags.HasError() { + return profileModel{}, diags + } + + switch count { + case 0: + diags.AddError( + "Lookup Kernel Profile", + "No Kernel profile found with exact name "+name+".", + ) + return profileModel{}, diags + case 1: + return flattenProfile(*profile) + default: + diags.AddError( + "Ambiguous Kernel Profile Name", + "Found multiple Kernel profiles with exact name "+name+". Configure id instead.", + ) + return profileModel{}, diags + } +} + +func (d *profileDataSource) findProfilesByName(ctx context.Context, projectID, name string, diags *diag.Diagnostics) (*kernel.Profile, int) { + var match *kernel.Profile + count := 0 + offset := int64(0) + seen := map[string]bool{} + + for { + page, err := d.client.ListProfilePage(ctx, projectID, name, offset) + if err != nil { + projectscope.AddError(diags, "Lookup Kernel Profile", projectID, err) + return nil, 0 + } + + for _, profile := range page.Items { + // Filter on the decoded name first: the server query is fuzzy, so + // unrelated rows (including ones with absent or malformed names) + // come back and must be skipped, not abort the lookup. Only rows + // claiming the requested name get raw-consistency validation. + if profile.Name != name { + continue + } + if !datasources.ValidResponseString(profile.JSON.Name.Raw(), profile.JSON.Name.Valid(), profile.Name) { + datasources.AddInvalidResponseField(diags, "Profile", "name") + return nil, 0 + } + // Dedupe by id: offset pagination can repeat a row across pages + // when the list shifts mid-scan, and a repeated row must not be + // mistaken for a second profile with the same name. + if seen[profile.ID] { + continue + } + seen[profile.ID] = true + count++ + if match == nil { + matched := profile + match = &matched + } + } + + if !page.HasNextPage { + break + } + offset = page.NextOffset + } + + return match, count +} + +func flattenProfile(profile kernel.Profile) (profileModel, diag.Diagnostics) { + var diags diag.Diagnostics + + if !datasources.ValidResponseString(profile.JSON.ID.Raw(), profile.JSON.ID.Valid(), profile.ID) { + datasources.AddInvalidResponseField(&diags, "Profile", "id") + } + if !datasources.ValidResponseTime(profile.JSON.CreatedAt.Raw(), profile.JSON.CreatedAt.Valid(), profile.CreatedAt) { + datasources.AddInvalidResponseField(&diags, "Profile", "created_at") + } + + name := types.StringNull() + if datasources.FieldPresent(profile.JSON.Name.Raw()) { + if !datasources.ValidResponseString(profile.JSON.Name.Raw(), profile.JSON.Name.Valid(), profile.Name) { + datasources.AddInvalidResponseField(&diags, "Profile", "name") + } else { + name = types.StringValue(profile.Name) + } + } + + if diags.HasError() { + return profileModel{}, diags + } + + return profileModel{ + ID: types.StringValue(profile.ID), + Name: name, + CreatedAt: types.StringValue(profile.CreatedAt.Format(time.RFC3339Nano)), + }, diags +} diff --git a/internal/datasources/profile/datasource_test.go b/internal/datasources/profile/datasource_test.go new file mode 100644 index 0000000..0f56cbe --- /dev/null +++ b/internal/datasources/profile/datasource_test.go @@ -0,0 +1,480 @@ +package profile + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/packages/respjson" + "github.com/kernel/terraform-provider-kernel/internal/kernelclient" +) + +var _ profileClient = kernelclient.Clients{} + +type fakeProfileClient struct { + defaultProjectID string + get func(context.Context, string, string) (*kernel.Profile, error) + list func(context.Context, string, string, int64) (kernelclient.ProfilePage, error) +} + +func (f fakeProfileClient) DefaultProjectID() string { + return f.defaultProjectID +} + +func (f fakeProfileClient) GetProfile(ctx context.Context, projectID, idOrName string) (*kernel.Profile, error) { + if f.get == nil { + return nil, errors.New("unexpected get") + } + return f.get(ctx, projectID, idOrName) +} + +func (f fakeProfileClient) ListProfilePage(ctx context.Context, projectID, query string, offset int64) (kernelclient.ProfilePage, error) { + if f.list == nil { + return kernelclient.ProfilePage{}, errors.New("unexpected list") + } + return f.list(ctx, projectID, query, offset) +} + +func TestDataSourceMetadataAndSchema(t *testing.T) { + t.Parallel() + + ds := NewDataSource() + + var metadata datasource.MetadataResponse + ds.Metadata(context.Background(), datasource.MetadataRequest{ProviderTypeName: "kernel"}, &metadata) + if metadata.TypeName != "kernel_profile" { + t.Fatalf("TypeName = %q, want kernel_profile", metadata.TypeName) + } + + var schema datasource.SchemaResponse + ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema) + for _, name := range []string{"id", "name", "project_id", "created_at"} { + if _, ok := schema.Schema.Attributes[name]; !ok { + t.Fatalf("schema missing %s attribute", name) + } + } + if _, ok := schema.Schema.Attributes["updated_at"]; ok { + t.Fatal("schema should not include runtime updated_at") + } + if _, ok := schema.Schema.Attributes["last_used_at"]; ok { + t.Fatal("schema should not include runtime last_used_at") + } +} + +func TestReadSetsTerraformState(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeProfileClient{ + list: listProfilePages(t, "Target", map[int64]kernelclient.ProfilePage{ + 0: profilePage(profileForTest("profile-target", "Target")), + }), + }) + + var schemaResp datasource.SchemaResponse + ds.Schema(context.Background(), datasource.SchemaRequest{}, &schemaResp) + + req := datasource.ReadRequest{ + Config: tfsdk.Config{ + Schema: schemaResp.Schema, + Raw: profileConfigValue(tftypes.NewValue(tftypes.String, nil), tftypes.NewValue(tftypes.String, "Target")), + }, + } + resp := datasource.ReadResponse{ + State: tfsdk.State{Schema: schemaResp.Schema}, + } + + ds.Read(context.Background(), req, &resp) + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + + var state profileModel + resp.Diagnostics.Append(resp.State.Get(context.Background(), &state)...) + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected state diagnostics: %v", resp.Diagnostics) + } + if state.ID.ValueString() != "profile-target" { + t.Fatalf("state id = %q, want profile-target", state.ID.ValueString()) + } + if state.Name.ValueString() != "Target" { + t.Fatalf("state name = %q, want Target", state.Name.ValueString()) + } +} + +func TestReadResolvesProjectScope(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + configProjectID types.String + defaultProjectID string + wantProjectID string + }{ + "explicit attribute wins over provider default": { + configProjectID: types.StringValue("proj_attr"), + defaultProjectID: "proj_default", + wantProjectID: "proj_attr", + }, + "unset attribute inherits provider default": { + configProjectID: types.StringNull(), + defaultProjectID: "proj_default", + wantProjectID: "proj_default", + }, + "nothing set stays unscoped": { + configProjectID: types.StringNull(), + defaultProjectID: "", + wantProjectID: "", + }, + } + + t.Run("unknown attribute errors instead of reading the default", func(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeProfileClient{defaultProjectID: "proj_default"}) + _, diags := ds.read(context.Background(), profileModel{ + ID: types.StringValue("profile-1"), + ProjectID: types.StringUnknown(), + }) + if !diags.HasError() { + t.Fatal("expected diagnostics for unknown project_id") + } + }) + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + var gotProjectID string + ds := newDataSourceWithClient(fakeProfileClient{ + defaultProjectID: test.defaultProjectID, + get: func(ctx context.Context, projectID, idOrName string) (*kernel.Profile, error) { + gotProjectID = projectID + profile := profileForTest("profile-1", "Profile") + return &profile, nil + }, + }) + + state, diags := ds.read(context.Background(), profileModel{ + ID: types.StringValue("profile-1"), + ProjectID: test.configProjectID, + }) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if gotProjectID != test.wantProjectID { + t.Fatalf("GetProfile project = %q, want %q", gotProjectID, test.wantProjectID) + } + if !state.ProjectID.Equal(test.configProjectID) { + t.Fatalf("state project_id = %v, want configured value %v echoed", state.ProjectID, test.configProjectID) + } + }) + } +} + +func TestReadProfileByID(t *testing.T) { + t.Parallel() + + var gotID string + ds := newDataSourceWithClient(fakeProfileClient{ + get: func(ctx context.Context, projectID, idOrName string) (*kernel.Profile, error) { + gotID = idOrName + profile := profileForTest("profile-1", "Profile") + return &profile, nil + }, + }) + + state, diags := ds.read(context.Background(), profileModel{ID: types.StringValue("profile-1")}) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if gotID != "profile-1" { + t.Fatalf("GetProfile id = %q, want profile-1", gotID) + } + if state.Name.ValueString() != "Profile" { + t.Fatalf("state name = %q, want Profile", state.Name.ValueString()) + } +} + +func TestReadProfileByIDAllowsNullableName(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeProfileClient{ + get: func(ctx context.Context, projectID, idOrName string) (*kernel.Profile, error) { + profile := profileWithoutNameForTest("profile-1") + return &profile, nil + }, + }) + + state, diags := ds.read(context.Background(), profileModel{ID: types.StringValue("profile-1")}) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if !state.Name.IsNull() { + t.Fatalf("state name = %q, want null", state.Name.ValueString()) + } +} + +func TestReadProfileByIDRejectsNameMatch(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeProfileClient{ + get: func(ctx context.Context, projectID, idOrName string) (*kernel.Profile, error) { + profile := profileForTest("profile-actual", idOrName) + return &profile, nil + }, + }) + + _, diags := ds.read(context.Background(), profileModel{ID: types.StringValue("Target")}) + if !diags.HasError() { + t.Fatal("expected diagnostics when profile ID lookup returns a different canonical ID") + } +} + +func TestReadLooksUpExactProfileName(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeProfileClient{ + list: listProfilePages(t, "Target", map[int64]kernelclient.ProfilePage{ + 0: profilePageWithNext(100, profileForTest("profile-other", "Other")), + 100: profilePage(profileForTest("profile-target", "Target")), + }), + }) + + state, diags := ds.read(context.Background(), profileModel{Name: types.StringValue("Target")}) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if state.ID.ValueString() != "profile-target" { + t.Fatalf("state id = %q, want profile-target", state.ID.ValueString()) + } +} + +func TestReadRejectsMissingExactProfileName(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeProfileClient{ + list: listProfilePages(t, "Target", map[int64]kernelclient.ProfilePage{ + 0: profilePage(profileForTest("profile-other", "Other")), + }), + }) + + _, diags := ds.read(context.Background(), profileModel{Name: types.StringValue("Target")}) + if !diags.HasError() { + t.Fatal("expected diagnostics for missing profile name") + } +} + +func TestReadRejectsAmbiguousProfileName(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeProfileClient{ + list: listProfilePages(t, "Target", map[int64]kernelclient.ProfilePage{ + 0: profilePageWithNext(100, profileForTest("profile-a", "Target")), + 100: profilePage(profileForTest("profile-b", "Target")), + }), + }) + + _, diags := ds.read(context.Background(), profileModel{Name: types.StringValue("Target")}) + if !diags.HasError() { + t.Fatal("expected diagnostics for ambiguous profile name") + } +} + +func TestReadDeduplicatesRepeatedProfileLookupRows(t *testing.T) { + t.Parallel() + + // Offset pagination can repeat a row across pages when the list shifts + // mid-scan; the same profile id twice is one match, not an ambiguity. + ds := newDataSourceWithClient(fakeProfileClient{ + list: listProfilePages(t, "Target", map[int64]kernelclient.ProfilePage{ + 0: profilePageWithNext(100, profileForTest("profile-target", "Target")), + 100: profilePage(profileForTest("profile-target", "Target")), + }), + }) + + state, diags := ds.read(context.Background(), profileModel{Name: types.StringValue("Target")}) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if state.ID.ValueString() != "profile-target" { + t.Fatalf("state id = %q, want profile-target", state.ID.ValueString()) + } +} + +func TestReadRejectsInvalidProfileLookupMatch(t *testing.T) { + t.Parallel() + + // A row that claims the requested name but has inconsistent raw JSON is a + // broken API response for the profile we would return — fail loud. + invalid := profileForTest("profile-invalid", "Target") + invalid.JSON.Name = respjson.NewInvalidField(`"Target"`) + + ds := newDataSourceWithClient(fakeProfileClient{ + list: listProfilePages(t, "Target", map[int64]kernelclient.ProfilePage{ + 0: profilePage(invalid), + }), + }) + + _, diags := ds.read(context.Background(), profileModel{Name: types.StringValue("Target")}) + if !diags.HasError() { + t.Fatal("expected diagnostics for invalid profile lookup match") + } +} + +func TestReadSkipsMalformedUnrelatedProfileLookupRows(t *testing.T) { + t.Parallel() + + // The server name query is fuzzy, so unrelated rows share the page with + // the exact match. A malformed name on an unrelated row must be skipped, + // not abort the lookup for the valid match. + invalid := profileForTest("profile-invalid", "Target Staging") + invalid.Name = "123" + invalid.JSON.Name = respjson.NewInvalidField("123") + + ds := newDataSourceWithClient(fakeProfileClient{ + list: listProfilePages(t, "Target", map[int64]kernelclient.ProfilePage{ + 0: profilePage(invalid, profileForTest("profile-target", "Target")), + }), + }) + + state, diags := ds.read(context.Background(), profileModel{Name: types.StringValue("Target")}) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if state.ID.ValueString() != "profile-target" { + t.Fatalf("state id = %q, want profile-target", state.ID.ValueString()) + } +} + +func TestReadRejectsInvalidProfileResponseField(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeProfileClient{ + get: func(ctx context.Context, projectID, idOrName string) (*kernel.Profile, error) { + profile := profileForTest("profile-1", "Profile") + profile.CreatedAt = time.Time{} + profile.JSON.CreatedAt = respjson.NewInvalidField("123") + return &profile, nil + }, + }) + + _, diags := ds.read(context.Background(), profileModel{ID: types.StringValue("profile-1")}) + if !diags.HasError() { + t.Fatal("expected diagnostics for invalid profile response field") + } +} + +func TestReadRejectsMissingProfileSelector(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeProfileClient{}) + + _, diags := ds.read(context.Background(), profileModel{}) + if !diags.HasError() { + t.Fatal("expected diagnostics for missing profile selector") + } +} + +func TestReadRejectsEmptyProfileSelectors(t *testing.T) { + t.Parallel() + + tests := map[string]profileModel{ + "empty id": { + ID: types.StringValue(""), + }, + "empty name": { + Name: types.StringValue(""), + }, + } + + for name, config := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + called := false + ds := newDataSourceWithClient(fakeProfileClient{ + get: func(ctx context.Context, projectID, idOrName string) (*kernel.Profile, error) { + called = true + return nil, errors.New("should not read profile") + }, + }) + + _, diags := ds.read(context.Background(), config) + if !diags.HasError() { + t.Fatal("expected diagnostics for empty selector") + } + if called { + t.Fatal("GetProfile was called for empty selector") + } + }) + } +} + +func profileForTest(id, name string) kernel.Profile { + return profileFromJSON(`{"id":"` + id + `","name":"` + name + `","created_at":"2026-06-05T12:00:00Z","updated_at":"2026-06-05T12:00:00Z","last_used_at":"2026-06-05T12:00:00Z"}`) +} + +func profileWithoutNameForTest(id string) kernel.Profile { + return profileFromJSON(`{"id":"` + id + `","name":null,"created_at":"2026-06-05T12:00:00Z","updated_at":"2026-06-05T12:00:00Z","last_used_at":"2026-06-05T12:00:00Z"}`) +} + +func profileFromJSON(body string) kernel.Profile { + var profile kernel.Profile + if err := json.Unmarshal([]byte(body), &profile); err != nil { + panic(err) + } + return profile +} + +func profileConfigValue(id, name tftypes.Value) tftypes.Value { + return tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "name": tftypes.String, + "project_id": tftypes.String, + "created_at": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "id": id, + "name": name, + "project_id": tftypes.NewValue(tftypes.String, nil), + "created_at": tftypes.NewValue(tftypes.String, nil), + }, + ) +} + +func listProfilePages(t *testing.T, wantQuery string, pages map[int64]kernelclient.ProfilePage) func(context.Context, string, string, int64) (kernelclient.ProfilePage, error) { + t.Helper() + + return func(ctx context.Context, projectID, query string, offset int64) (kernelclient.ProfilePage, error) { + if query != wantQuery { + t.Fatalf("ListProfilePage query = %q, want %q", query, wantQuery) + } + + page, ok := pages[offset] + if !ok { + t.Fatalf("unexpected profile page offset: %d", offset) + } + return page, nil + } +} + +func profilePage(profiles ...kernel.Profile) kernelclient.ProfilePage { + return kernelclient.ProfilePage{Items: profiles} +} + +func profilePageWithNext(nextOffset int64, profiles ...kernel.Profile) kernelclient.ProfilePage { + return kernelclient.ProfilePage{ + Items: profiles, + NextOffset: nextOffset, + HasNextPage: true, + } +} diff --git a/internal/datasources/project/datasource.go b/internal/datasources/project/datasource.go index ca2da70..c6772e1 100644 --- a/internal/datasources/project/datasource.go +++ b/internal/datasources/project/datasource.go @@ -2,8 +2,6 @@ package project import ( "context" - "encoding/json" - "strings" "time" "github.com/hashicorp/terraform-plugin-framework/datasource" @@ -11,7 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" kernel "github.com/kernel/kernel-go-sdk" - "github.com/kernel/terraform-provider-kernel/internal/kernelclient" + "github.com/kernel/terraform-provider-kernel/internal/datasources" ) var ( @@ -22,7 +20,6 @@ var ( type projectClient interface { DefaultProjectID() string GetProject(context.Context, string) (*kernel.Project, error) - ListProjectPage(context.Context, string, int64) (kernelclient.ProjectPage, error) } type projectDataSource struct { @@ -37,14 +34,6 @@ type projectModel struct { UpdatedAt types.String `tfsdk:"updated_at"` } -type projectSelector int - -const ( - projectSelectorProvider projectSelector = iota - projectSelectorID - projectSelectorName -) - func NewDataSource() datasource.DataSource { return &projectDataSource{} } @@ -130,17 +119,35 @@ func (d *projectDataSource) read(ctx context.Context, config projectModel) (proj return projectModel{}, diags } - selector, selectorDiags := resolveProjectSelector(config.ID, config.Name) + selector, selectorDiags := datasources.ResolveIDNameSelector("Project", "kernel_project", config.ID, config.Name) diags.Append(selectorDiags...) if diags.HasError() { return projectModel{}, diags } - if selector == projectSelectorID { + if selector.HasID { return d.get(ctx, config.ID.ValueString()) } - if selector == projectSelectorName { - return d.lookupName(ctx, config.Name.ValueString()) + if selector.HasName { + // The API resolves the GET path parameter by id or name (names are + // unique within an organization), so a single Get covers name lookups. + name := config.Name.ValueString() + state, getDiags := d.get(ctx, name) + diags.Append(getDiags...) + if diags.HasError() { + return projectModel{}, diags + } + // The API tries id resolution first, so a name that collides with + // another project's id silently returns that project. Enforce the + // exact-name contract the selector promises. + if state.Name.ValueString() != name { + diags.AddError( + "Lookup Kernel Project", + "Kernel resolved "+name+" to a project whose name does not match; the value likely collides with a project id. Configure id instead.", + ) + return projectModel{}, diags + } + return state, diags } id := d.client.DefaultProjectID() @@ -168,126 +175,23 @@ func (d *projectDataSource) get(ctx context.Context, id string) (projectModel, d return flattenProject(*project) } -func (d *projectDataSource) lookupName(ctx context.Context, name string) (projectModel, diag.Diagnostics) { - var diags diag.Diagnostics - - project, count := d.findProjectsByName(ctx, name, &diags) - if diags.HasError() { - return projectModel{}, diags - } - - switch count { - case 0: - diags.AddError( - "Lookup Kernel Project", - "No Kernel project found with exact name "+name+".", - ) - return projectModel{}, diags - case 1: - return flattenProject(*project) - default: - diags.AddError( - "Ambiguous Kernel Project Name", - "Found multiple Kernel projects with exact name "+name+". Configure id instead.", - ) - return projectModel{}, diags - } -} - -func (d *projectDataSource) findProjectsByName(ctx context.Context, name string, diags *diag.Diagnostics) (*kernel.Project, int) { - var match *kernel.Project - count := 0 - offset := int64(0) - - for { - page, err := d.client.ListProjectPage(ctx, name, offset) - if err != nil { - diags.AddError("Lookup Kernel Project", err.Error()) - return nil, 0 - } - - for _, project := range page.Items { - if !validProjectString(project.JSON.Name.Raw(), project.JSON.Name.Valid(), project.Name) { - addInvalidProjectField(diags, "name") - return nil, 0 - } - if project.Name != name { - continue - } - count++ - if match == nil { - matched := project - match = &matched - } - } - - if !page.HasNextPage { - break - } - offset = page.NextOffset - } - - return match, count -} - -func resolveProjectSelector(id, name types.String) (projectSelector, diag.Diagnostics) { - var diags diag.Diagnostics - - if id.IsUnknown() || name.IsUnknown() { - diags.AddError( - "Unknown Project Selector", - "Project id and name must be known before reading the data source.", - ) - return projectSelectorProvider, diags - } - if !id.IsNull() && id.ValueString() == "" { - diags.AddError( - "Empty Project ID", - "Project id must be omitted or a non-empty string.", - ) - } - if !name.IsNull() && name.ValueString() == "" { - diags.AddError( - "Empty Project Name", - "Project name must be omitted or a non-empty string.", - ) - } - if diags.HasError() { - return projectSelectorProvider, diags - } - if !id.IsNull() && !name.IsNull() { - diags.AddError( - "Conflicting Project Selectors", - "Configure only one of id or name for kernel_project.", - ) - return projectSelectorProvider, diags - } - if !id.IsNull() { - return projectSelectorID, diags - } - if !name.IsNull() { - return projectSelectorName, diags - } - return projectSelectorProvider, diags -} - func flattenProject(project kernel.Project) (projectModel, diag.Diagnostics) { var diags diag.Diagnostics - if !validProjectString(project.JSON.ID.Raw(), project.JSON.ID.Valid(), project.ID) { - addInvalidProjectField(&diags, "id") + if !datasources.ValidResponseString(project.JSON.ID.Raw(), project.JSON.ID.Valid(), project.ID) { + datasources.AddInvalidResponseField(&diags, "Project", "id") } - if !validProjectString(project.JSON.Name.Raw(), project.JSON.Name.Valid(), project.Name) { - addInvalidProjectField(&diags, "name") + if !datasources.ValidResponseString(project.JSON.Name.Raw(), project.JSON.Name.Valid(), project.Name) { + datasources.AddInvalidResponseField(&diags, "Project", "name") } - if !validProjectString(project.JSON.Status.Raw(), project.JSON.Status.Valid(), string(project.Status)) { - addInvalidProjectField(&diags, "status") + if !datasources.ValidResponseString(project.JSON.Status.Raw(), project.JSON.Status.Valid(), string(project.Status)) { + datasources.AddInvalidResponseField(&diags, "Project", "status") } - if !validProjectTime(project.JSON.CreatedAt.Raw(), project.JSON.CreatedAt.Valid(), project.CreatedAt) { - addInvalidProjectField(&diags, "created_at") + if !datasources.ValidResponseTime(project.JSON.CreatedAt.Raw(), project.JSON.CreatedAt.Valid(), project.CreatedAt) { + datasources.AddInvalidResponseField(&diags, "Project", "created_at") } - if !validProjectTime(project.JSON.UpdatedAt.Raw(), project.JSON.UpdatedAt.Valid(), project.UpdatedAt) { - addInvalidProjectField(&diags, "updated_at") + if !datasources.ValidResponseTime(project.JSON.UpdatedAt.Raw(), project.JSON.UpdatedAt.Valid(), project.UpdatedAt) { + datasources.AddInvalidResponseField(&diags, "Project", "updated_at") } if diags.HasError() { return projectModel{}, diags @@ -301,38 +205,3 @@ func flattenProject(project kernel.Project) (projectModel, diag.Diagnostics) { UpdatedAt: types.StringValue(project.UpdatedAt.Format(time.RFC3339Nano)), }, diags } - -func validProjectString(raw string, valid bool, value string) bool { - if !projectFieldPresent(raw) || !valid || value == "" { - return false - } - - var decoded string - if err := json.Unmarshal([]byte(raw), &decoded); err != nil { - return false - } - return decoded == value -} - -func validProjectTime(raw string, valid bool, value time.Time) bool { - if !projectFieldPresent(raw) || !valid || value.IsZero() { - return false - } - - var decoded time.Time - if err := json.Unmarshal([]byte(raw), &decoded); err != nil { - return false - } - return decoded.Equal(value) -} - -func projectFieldPresent(raw string) bool { - return raw != "" && strings.TrimSpace(raw) != "null" -} - -func addInvalidProjectField(diags *diag.Diagnostics, field string) { - diags.AddError( - "Invalid Kernel Project Response", - "Kernel returned a project with missing or invalid required field "+field+".", - ) -} diff --git a/internal/datasources/project/datasource_test.go b/internal/datasources/project/datasource_test.go index b9185cd..3b6170a 100644 --- a/internal/datasources/project/datasource_test.go +++ b/internal/datasources/project/datasource_test.go @@ -21,25 +21,17 @@ var _ projectClient = kernelclient.Clients{} type fakeProjectClient struct { defaultProjectID string get func(context.Context, string) (*kernel.Project, error) - list func(context.Context, string, int64) (kernelclient.ProjectPage, error) } func (f fakeProjectClient) DefaultProjectID() string { return f.defaultProjectID } -func (f fakeProjectClient) GetProject(ctx context.Context, id string) (*kernel.Project, error) { +func (f fakeProjectClient) GetProject(ctx context.Context, idOrName string) (*kernel.Project, error) { if f.get == nil { - return nil, errors.New("unexpected get") + return nil, errors.New("unexpected GetProject call") } - return f.get(ctx, id) -} - -func (f fakeProjectClient) ListProjectPage(ctx context.Context, query string, offset int64) (kernelclient.ProjectPage, error) { - if f.list == nil { - return kernelclient.ProjectPage{}, errors.New("unexpected list") - } - return f.list(ctx, query, offset) + return f.get(ctx, idOrName) } func TestDataSourceMetadataAndSchema(t *testing.T) { @@ -94,9 +86,10 @@ func TestReadSetsTerraformState(t *testing.T) { t.Parallel() ds := newDataSourceWithClient(fakeProjectClient{ - list: listProjectPages(t, "Target", map[int64]kernelclient.ProjectPage{ - 0: projectPage(projectForTest("project-target", "Target")), - }), + get: func(ctx context.Context, id string) (*kernel.Project, error) { + project := projectForTest("project-target", "Target") + return &project, nil + }, }) var schemaResp datasource.SchemaResponse @@ -133,57 +126,61 @@ func TestReadSetsTerraformState(t *testing.T) { } } -func TestReadLooksUpExactProjectName(t *testing.T) { +func TestReadLooksUpProjectByName(t *testing.T) { t.Parallel() + var gotIDOrName string ds := newDataSourceWithClient(fakeProjectClient{ - list: listProjectPages(t, "Target", map[int64]kernelclient.ProjectPage{ - 0: projectPageWithNext(100, projectForTest("project-other", "Other")), - 100: projectPage(projectForTest("project-target", "Target")), - }), + get: func(ctx context.Context, id string) (*kernel.Project, error) { + gotIDOrName = id + project := projectForTest("project-target", "Target") + return &project, nil + }, }) state, diags := ds.read(context.Background(), projectModel{Name: types.StringValue("Target")}) if diags.HasError() { t.Fatalf("unexpected diagnostics: %v", diags) } + if gotIDOrName != "Target" { + t.Fatalf("GetProject id = %q, want Target (server resolves name)", gotIDOrName) + } if state.ID.ValueString() != "project-target" { t.Fatalf("state id = %q, want project-target", state.ID.ValueString()) } } -func TestReadRejectsAmbiguousProjectName(t *testing.T) { +func TestReadRejectsNameLookupResolvedAsID(t *testing.T) { t.Parallel() + // The API resolves the GET path parameter by id first, so a configured + // name that collides with another project's id returns that project. + // The name selector promises an exact name match — reject the mismatch. ds := newDataSourceWithClient(fakeProjectClient{ - list: listProjectPages(t, "Target", map[int64]kernelclient.ProjectPage{ - 0: projectPageWithNext(100, projectForTest("project-a", "Target")), - 100: projectPage(projectForTest("project-b", "Target")), - }), + get: func(ctx context.Context, id string) (*kernel.Project, error) { + project := projectForTest("proj-collide", "Different Name") + return &project, nil + }, }) - _, diags := ds.read(context.Background(), projectModel{Name: types.StringValue("Target")}) + _, diags := ds.read(context.Background(), projectModel{Name: types.StringValue("proj-collide")}) if !diags.HasError() { - t.Fatal("expected diagnostics for ambiguous project name") + t.Fatal("expected diagnostics for name lookup resolved as a project id") } } -func TestReadRejectsInvalidProjectLookupCandidate(t *testing.T) { +func TestReadRejectsProjectNotFound(t *testing.T) { t.Parallel() - invalid := projectForTest("project-invalid", "Target") - invalid.Name = "123" - invalid.JSON.Name = respjson.NewInvalidField("123") - ds := newDataSourceWithClient(fakeProjectClient{ - list: listProjectPages(t, "Target", map[int64]kernelclient.ProjectPage{ - 0: projectPage(invalid), - }), + get: func(ctx context.Context, id string) (*kernel.Project, error) { + return nil, errors.New("404 Not Found: project not found") + }, }) - _, diags := ds.read(context.Background(), projectModel{Name: types.StringValue("Target")}) + _, diags := ds.read(context.Background(), projectModel{Name: types.StringValue("Missing")}) if !diags.HasError() { - t.Fatal("expected diagnostics for invalid project lookup candidate") + t.Fatal("expected diagnostics for a not-found project") } } @@ -299,31 +296,3 @@ func projectConfigValue(id, name tftypes.Value) tftypes.Value { }, ) } - -func listProjectPages(t *testing.T, wantQuery string, pages map[int64]kernelclient.ProjectPage) func(context.Context, string, int64) (kernelclient.ProjectPage, error) { - t.Helper() - - return func(ctx context.Context, query string, offset int64) (kernelclient.ProjectPage, error) { - if query != wantQuery { - t.Fatalf("ListProjectPage query = %q, want %q", query, wantQuery) - } - - page, ok := pages[offset] - if !ok { - t.Fatalf("unexpected project page offset: %d", offset) - } - return page, nil - } -} - -func projectPage(projects ...kernel.Project) kernelclient.ProjectPage { - return kernelclient.ProjectPage{Items: projects} -} - -func projectPageWithNext(nextOffset int64, projects ...kernel.Project) kernelclient.ProjectPage { - return kernelclient.ProjectPage{ - Items: projects, - NextOffset: nextOffset, - HasNextPage: true, - } -} diff --git a/internal/kernelclient/client.go b/internal/kernelclient/client.go index 5e99058..a2c3c59 100644 --- a/internal/kernelclient/client.go +++ b/internal/kernelclient/client.go @@ -15,12 +15,14 @@ const DefaultRequestTimeout = 2 * time.Minute const nameLookupLimit int64 = 100 -type ProjectPage struct { - Items []kernel.Project +type Page[T any] struct { + Items []T NextOffset int64 HasNextPage bool } +type ProfilePage = Page[kernel.Profile] + // Config configures the shared Kernel API clients. ProjectID is a default // only; the client never applies it implicitly. type Config struct { @@ -75,13 +77,26 @@ func (c Clients) DefaultProjectID() string { // Projects are org-scoped, so their methods take no project. -func (c Clients) GetProject(ctx context.Context, id string) (*kernel.Project, error) { - return c.projects.Get(ctx, id) +// GetProject resolves a project by ID or by name; the API treats the path +// parameter as id-or-name (names are unique within an organization). +func (c Clients) GetProject(ctx context.Context, idOrName string) (*kernel.Project, error) { + return c.projects.Get(ctx, idOrName) +} + +// The remaining methods are project-scoped and take the resolved project +// for each call. + +func (c Clients) GetProxy(ctx context.Context, projectID, id string) (*kernel.ProxyGetResponse, error) { + return c.proxies.Get(ctx, id, scope(projectID)...) +} + +func (c Clients) GetProfile(ctx context.Context, projectID, idOrName string) (*kernel.Profile, error) { + return c.profiles.Get(ctx, idOrName, scope(projectID)...) } -func (c Clients) ListProjectPage(ctx context.Context, query string, offset int64) (ProjectPage, error) { +func (c Clients) ListProfilePage(ctx context.Context, projectID, query string, offset int64) (ProfilePage, error) { var raw *http.Response - params := kernel.ProjectListParams{ + params := kernel.ProfileListParams{ Query: kernel.String(query), Limit: kernel.Int(nameLookupLimit), } @@ -89,36 +104,25 @@ func (c Clients) ListProjectPage(ctx context.Context, query string, offset int64 params.Offset = kernel.Int(offset) } - page, err := c.projects.List(ctx, params, option.WithResponseInto(&raw)) + page, err := c.profiles.List(ctx, params, scope(projectID, option.WithResponseInto(&raw))...) if err != nil { - return ProjectPage{}, err + return ProfilePage{}, err } if page == nil { - return ProjectPage{}, fmt.Errorf("Kernel returned an empty project list response") + return ProfilePage{}, fmt.Errorf("Kernel returned an empty profile list response") } - next, ok, err := projectLookupNextOffset(raw, offset) + next, ok, err := lookupNextOffset(raw, offset, "profile") if err != nil { - return ProjectPage{}, err + return ProfilePage{}, err } - return ProjectPage{ + return ProfilePage{ Items: page.Items, NextOffset: next, HasNextPage: ok, }, nil } -// The remaining methods are project-scoped and take the resolved project -// for each call. - -func (c Clients) GetProxy(ctx context.Context, projectID, id string) (*kernel.ProxyGetResponse, error) { - return c.proxies.Get(ctx, id, scope(projectID)...) -} - -func (c Clients) GetProfile(ctx context.Context, projectID, idOrName string) (*kernel.Profile, error) { - return c.profiles.Get(ctx, idOrName, scope(projectID)...) -} - func (c Clients) CreateBrowserPool(ctx context.Context, projectID string, params kernel.BrowserPoolNewParams) (*kernel.BrowserPool, error) { return c.browserPools.New(ctx, params, scope(projectID, noMutationRetries())...) } @@ -150,13 +154,27 @@ func noMutationRetries() option.RequestOption { return option.WithMaxRetries(0) } -func projectLookupNextOffset(raw *http.Response, current int64) (int64, bool, error) { +func lookupNextOffset(raw *http.Response, current int64, kind string) (int64, bool, error) { if raw == nil { return 0, false, fmt.Errorf("Kernel returned an empty pagination response") } + hasMore, hasMoreSet, err := lookupHasMore(raw, kind) + if err != nil { + return 0, false, err + } + // An explicit X-Has-More is the authoritative continuation signal: a + // stale X-Next-Offset alongside has-more false must not keep a scan + // paging past the end the server declared. + if hasMoreSet && !hasMore { + return 0, false, nil + } + value := raw.Header.Get("X-Next-Offset") if value == "" { + if hasMore { + return 0, false, fmt.Errorf("Kernel %s pagination reported more results without a next offset", kind) + } return 0, false, nil } @@ -165,11 +183,15 @@ func projectLookupNextOffset(raw *http.Response, current int64) (int64, bool, er return 0, false, fmt.Errorf("invalid Kernel pagination next offset %q: %w", value, err) } if next <= 0 { + if hasMore { + return 0, false, fmt.Errorf("Kernel %s pagination reported more results with non-positive next offset %d", kind, next) + } return 0, false, nil } if next <= current { return 0, false, fmt.Errorf( - "non-advancing Kernel project pagination: current offset %d, next offset %d", + "non-advancing Kernel %s pagination: current offset %d, next offset %d", + kind, current, next, ) @@ -178,6 +200,22 @@ func projectLookupNextOffset(raw *http.Response, current int64) (int64, bool, er return next, true, nil } +// lookupHasMore reports the X-Has-More value and whether the header was +// present at all; callers treat an explicit value as authoritative and fall +// back to offset semantics when the header is absent. +func lookupHasMore(raw *http.Response, kind string) (bool, bool, error) { + value := raw.Header.Get("X-Has-More") + if value == "" { + return false, false, nil + } + + hasMore, err := strconv.ParseBool(value) + if err != nil { + return false, false, fmt.Errorf("invalid Kernel %s pagination has-more %q: %w", kind, value, err) + } + return hasMore, true, nil +} + func requestOptions(config Config, clientOpts clientOptions) []option.RequestOption { requestTimeout := config.RequestTimeout if requestTimeout == 0 { diff --git a/internal/kernelclient/client_test.go b/internal/kernelclient/client_test.go index 5af881a..87f897e 100644 --- a/internal/kernelclient/client_test.go +++ b/internal/kernelclient/client_test.go @@ -80,27 +80,95 @@ func TestClientsSendProjectHeaderOnlyWhenExplicitlyScoped(t *testing.T) { } } -func TestListProjectPageReadsItemsAndNextOffset(t *testing.T) { +func TestListProfilePageRejectsRepeatedNextOffset(t *testing.T) { + t.Parallel() + + clients := New(Config{ + APIKey: "test-api-key", + BaseURL: "https://api.example", + ProjectID: "default_project", + }, WithHTTPClient(pagedListHTTPClient(t, nil, "/profiles", "project_123", "Target", []lookupPage{ + {offset: "100", body: profileListPage(profileJSON("profile-b", "Other")), next: "100"}, + }))) + + _, err := clients.ListProfilePage(context.Background(), "project_123", "Target", 100) + if err == nil { + t.Fatal("expected repeated next offset error") + } + for _, want := range []string{"non-advancing", "current offset 100", "next offset 100"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %q, want %q", err.Error(), want) + } + } +} + +func TestListProfilePageRejectsHasMoreWithoutNextOffset(t *testing.T) { + t.Parallel() + + clients := New(Config{ + APIKey: "test-api-key", + BaseURL: "https://api.example", + ProjectID: "default_project", + }, WithHTTPClient(pagedListHTTPClient(t, nil, "/profiles", "project_123", "Target", []lookupPage{ + {body: profileListPage(profileJSON("profile-b", "Other")), hasMore: "true"}, + }))) + + _, err := clients.ListProfilePage(context.Background(), "project_123", "Target", 0) + if err == nil { + t.Fatal("expected has-more without next offset error") + } + for _, want := range []string{"profile pagination", "more results", "without a next offset"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %q, want %q", err.Error(), want) + } + } +} + +func TestListProfilePageStopsOnExplicitHasMoreFalse(t *testing.T) { + t.Parallel() + + // An explicit X-Has-More: false is the authoritative end-of-results + // signal; a stale X-Next-Offset alongside it must not keep the scan + // paging past the end the server declared. + clients := New(Config{ + APIKey: "test-api-key", + BaseURL: "https://api.example", + ProjectID: "default_project", + }, WithHTTPClient(pagedListHTTPClient(t, nil, "/profiles", "project_123", "Target", []lookupPage{ + {body: profileListPage(profileJSON("profile-target", "Target")), next: "100", hasMore: "false"}, + }))) + + page, err := clients.ListProfilePage(context.Background(), "project_123", "Target", 0) + if err != nil { + t.Fatalf("ListProfilePage returned error: %v", err) + } + if page.HasNextPage { + t.Fatal("HasNextPage = true, want false when X-Has-More is explicitly false") + } +} + +func TestListProfilePageReadsItemsAndNextOffset(t *testing.T) { t.Parallel() var requests []capturedRequest clients := New(Config{ - APIKey: "test-api-key", - BaseURL: "https://api.example", - }, WithHTTPClient(projectLookupHTTPClient(t, &requests, "Target", []lookupPage{ - {body: projectListPage(projectJSON("project-other", "Other")), next: "100"}, - {offset: "100", body: projectListPage(projectJSON("project-target", "Target"))}, + APIKey: "test-api-key", + BaseURL: "https://api.example", + ProjectID: "default_project", + }, WithHTTPClient(pagedListHTTPClient(t, &requests, "/profiles", "project_123", "Target", []lookupPage{ + {body: profileListPage(profileJSON("profile-other", "Other")), next: "100"}, + {offset: "100", body: profileListPage(profileJSON("profile-target", "Target"))}, }))) - page, err := clients.ListProjectPage(context.Background(), "Target", 0) + page, err := clients.ListProfilePage(context.Background(), "project_123", "Target", 0) if err != nil { - t.Fatalf("ListProjectPage returned error: %v", err) + t.Fatalf("ListProfilePage returned error: %v", err) } if got, want := len(page.Items), 1; got != want { t.Fatalf("items length = %d, want %d", got, want) } - if page.Items[0].ID != "project-other" { - t.Fatalf("project id = %q, want project-other", page.Items[0].ID) + if page.Items[0].ID != "profile-other" { + t.Fatalf("profile id = %q, want profile-other", page.Items[0].ID) } if !page.HasNextPage { t.Fatal("HasNextPage = false, want true") @@ -109,42 +177,21 @@ func TestListProjectPageReadsItemsAndNextOffset(t *testing.T) { t.Fatalf("NextOffset = %d, want 100", page.NextOffset) } - page, err = clients.ListProjectPage(context.Background(), "Target", 100) + page, err = clients.ListProfilePage(context.Background(), "project_123", "Target", 100) if err != nil { - t.Fatalf("ListProjectPage returned error: %v", err) + t.Fatalf("ListProfilePage returned error: %v", err) } if page.HasNextPage { t.Fatal("HasNextPage = true, want false") } - if page.Items[0].ID != "project-target" { - t.Fatalf("project id = %q, want project-target", page.Items[0].ID) + if page.Items[0].ID != "profile-target" { + t.Fatalf("profile id = %q, want profile-target", page.Items[0].ID) } if got, want := len(requests), 2; got != want { t.Fatalf("request count = %d, want %d", got, want) } } -func TestListProjectPageRejectsRepeatedNextOffset(t *testing.T) { - t.Parallel() - - clients := New(Config{ - APIKey: "test-api-key", - BaseURL: "https://api.example", - }, WithHTTPClient(projectLookupHTTPClient(t, nil, "Target", []lookupPage{ - {offset: "100", body: projectListPage(projectJSON("project-b", "Other")), next: "100"}, - }))) - - _, err := clients.ListProjectPage(context.Background(), "Target", 100) - if err == nil { - t.Fatal("expected repeated next offset error") - } - for _, want := range []string{"non-advancing", "current offset 100", "next offset 100"} { - if !strings.Contains(err.Error(), want) { - t.Fatalf("error = %q, want %q", err.Error(), want) - } - } -} - func TestClientsDoNotReadSDKEnvironmentDefaults(t *testing.T) { t.Setenv("KERNEL_BASE_URL", "https://env.example") t.Setenv("KERNEL_API_KEY", "env-api-key") @@ -300,9 +347,10 @@ type capturedRequest struct { } type lookupPage struct { - offset string - next string - body string + offset string + next string + hasMore string + body string } func recordingHTTPClient(requests *[]capturedRequest, responseBody func(*http.Request) string) *http.Client { @@ -362,14 +410,21 @@ func captureRequest(requests *[]capturedRequest, req *http.Request) { }) } -func nextOffset(offset string) http.Header { - if offset == "" { +func lookupHeaders(page lookupPage) http.Header { + if page.next == "" && page.hasMore == "" { return nil } - return http.Header{"X-Next-Offset": []string{offset}} + header := http.Header{} + if page.next != "" { + header.Set("X-Next-Offset", page.next) + } + if page.hasMore != "" { + header.Set("X-Has-More", page.hasMore) + } + return header } -func projectLookupHTTPClient(t *testing.T, requests *[]capturedRequest, wantQuery string, pages []lookupPage) *http.Client { +func pagedListHTTPClient(t *testing.T, requests *[]capturedRequest, path, projectID, wantQuery string, pages []lookupPage) *http.Client { t.Helper() byOffset := make(map[string]lookupPage, len(pages)) @@ -378,14 +433,14 @@ func projectLookupHTTPClient(t *testing.T, requests *[]capturedRequest, wantQuer } return recordingHTTPClientWithHeaders(requests, func(req *http.Request) (string, http.Header) { - if req.URL.Path != "/org/projects" { - t.Fatalf("path = %s, want /org/projects", req.URL.Path) + if req.URL.Path != path { + t.Fatalf("path = %s, want %s", req.URL.Path, path) } if got := req.URL.Query().Get("query"); got != wantQuery { t.Fatalf("query = %q, want %q", got, wantQuery) } - if got := req.Header.Get("X-Kernel-Project-Id"); got != "" { - t.Fatalf("project header = %q, want empty", got) + if got := req.Header.Get("X-Kernel-Project-Id"); got != projectID { + t.Fatalf("project header = %q, want %q", got, projectID) } offset := req.URL.Query().Get("offset") @@ -394,14 +449,14 @@ func projectLookupHTTPClient(t *testing.T, requests *[]capturedRequest, wantQuer t.Fatalf("unexpected offset: %q", offset) } - return page.body, nextOffset(page.next) + return page.body, lookupHeaders(page) }) } -func projectListPage(projects ...string) string { - return "[" + strings.Join(projects, ",") + "]" +func profileListPage(profiles ...string) string { + return "[" + strings.Join(profiles, ",") + "]" } -func projectJSON(id, name string) string { - return `{"id":"` + id + `","name":"` + name + `","status":"active","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}` +func profileJSON(id, name string) string { + return `{"id":"` + id + `","name":"` + name + `","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}` } diff --git a/internal/projectscope/projectscope.go b/internal/projectscope/projectscope.go index bd8581e..dc6bdcb 100644 --- a/internal/projectscope/projectscope.go +++ b/internal/projectscope/projectscope.go @@ -31,6 +31,22 @@ func Resolve(attribute types.String, defaultProjectID string) string { return defaultProjectID } +// ResolveDataSource resolves the project for a data source read. Terraform +// normally defers data source reads until the configuration is wholly known, +// but the framework documents that Config may still carry unknown values; an +// unknown project must error rather than silently read the provider default. +func ResolveDataSource(diags *diag.Diagnostics, attribute types.String, defaultProjectID string) string { + if attribute.IsUnknown() { + diags.AddAttributeError( + path.Root("project_id"), + "Unknown Kernel Project ID", + "project_id is not known during this read. Terraform defers data source reads until the value is known; if this error appears, re-run the operation or report it as a provider bug.", + ) + return "" + } + return Resolve(attribute, defaultProjectID) +} + // StateValue converts a resolved project into its state representation: // null when unscoped, so reads stay with the API key's binding. func StateValue(projectID string) types.String { diff --git a/internal/projectscope/projectscope_test.go b/internal/projectscope/projectscope_test.go index eb2987d..abe844e 100644 --- a/internal/projectscope/projectscope_test.go +++ b/internal/projectscope/projectscope_test.go @@ -176,3 +176,23 @@ func TestIsNotFound(t *testing.T) { }) } } + +func TestResolveDataSource(t *testing.T) { + t.Parallel() + + var diags diag.Diagnostics + if got := ResolveDataSource(&diags, types.StringValue("proj_attr"), "proj_default"); got != "proj_attr" || diags.HasError() { + t.Fatalf("explicit attribute: got %q, diags %v", got, diags) + } + if got := ResolveDataSource(&diags, types.StringNull(), "proj_default"); got != "proj_default" || diags.HasError() { + t.Fatalf("null attribute: got %q, diags %v", got, diags) + } + + got := ResolveDataSource(&diags, types.StringUnknown(), "proj_default") + if got != "" { + t.Fatalf("unknown attribute resolved to %q, want empty", got) + } + if !diags.HasError() { + t.Fatal("unknown attribute must error, not fall back to the default") + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 0a45fde..27a76b9 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" + "github.com/kernel/terraform-provider-kernel/internal/datasources/profile" "github.com/kernel/terraform-provider-kernel/internal/datasources/project" "github.com/kernel/terraform-provider-kernel/internal/kernelclient" "github.com/kernel/terraform-provider-kernel/internal/resources/browserpool" @@ -84,5 +85,6 @@ func (p *kernelProvider) Resources(ctx context.Context) []func() resource.Resour func (p *kernelProvider) DataSources(ctx context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{ project.NewDataSource, + profile.NewDataSource, } } diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index ea57ec5..50af0cd 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -56,18 +56,25 @@ func TestProviderRegistersDataSources(t *testing.T) { p := provider.New("test")() dataSources := p.DataSources(context.Background()) - if len(dataSources) != 1 { - t.Fatalf("DataSources length = %d, want 1", len(dataSources)) + if len(dataSources) != 2 { + t.Fatalf("DataSources length = %d, want 2", len(dataSources)) } - var resp datasource.MetadataResponse - dataSources[0]().Metadata( - context.Background(), - datasource.MetadataRequest{ProviderTypeName: "kernel"}, - &resp, - ) - if resp.TypeName != "kernel_project" { - t.Fatalf("data source TypeName = %q, want kernel_project", resp.TypeName) + got := make(map[string]bool, len(dataSources)) + for _, factory := range dataSources { + var resp datasource.MetadataResponse + factory().Metadata( + context.Background(), + datasource.MetadataRequest{ProviderTypeName: "kernel"}, + &resp, + ) + got[resp.TypeName] = true + } + + for _, want := range []string{"kernel_project", "kernel_profile"} { + if !got[want] { + t.Fatalf("missing data source %s; got %v", want, got) + } } }