From 7c0b29fbb342baeceb8d2cc46cab6dd62e75e501 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:08:55 -0400 Subject: [PATCH] Add project data source Add the read-only kernel_project data source as the first data-source slice. Keep exact lookup and ambiguity policy in the Terraform data source while kernelclient exposes only a durable paged project-list primitive. --- internal/datasources/project/datasource.go | 338 ++++++++++++++++++ .../datasources/project/datasource_test.go | 329 +++++++++++++++++ internal/kernelclient/client.go | 67 ++++ internal/kernelclient/client_test.go | 167 ++++++++- internal/provider/provider.go | 5 +- internal/provider/provider_test.go | 23 +- 6 files changed, 914 insertions(+), 15 deletions(-) create mode 100644 internal/datasources/project/datasource.go create mode 100644 internal/datasources/project/datasource_test.go diff --git a/internal/datasources/project/datasource.go b/internal/datasources/project/datasource.go new file mode 100644 index 0000000..ca2da70 --- /dev/null +++ b/internal/datasources/project/datasource.go @@ -0,0 +1,338 @@ +package project + +import ( + "context" + "encoding/json" + "strings" + "time" + + "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/types" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/terraform-provider-kernel/internal/kernelclient" +) + +var ( + _ datasource.DataSource = (*projectDataSource)(nil) + _ datasource.DataSourceWithConfigure = (*projectDataSource)(nil) +) + +type projectClient interface { + DefaultProjectID() string + GetProject(context.Context, string) (*kernel.Project, error) + ListProjectPage(context.Context, string, int64) (kernelclient.ProjectPage, error) +} + +type projectDataSource struct { + client projectClient +} + +type projectModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Status types.String `tfsdk:"status"` + CreatedAt types.String `tfsdk:"created_at"` + UpdatedAt types.String `tfsdk:"updated_at"` +} + +type projectSelector int + +const ( + projectSelectorProvider projectSelector = iota + projectSelectorID + projectSelectorName +) + +func NewDataSource() datasource.DataSource { + return &projectDataSource{} +} + +func newDataSourceWithClient(client projectClient) *projectDataSource { + return &projectDataSource{client: client} +} + +func (d *projectDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_project" +} + +func (d *projectDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = dschema.Schema{ + MarkdownDescription: "Lookup durable Kernel project metadata.", + Attributes: map[string]dschema.Attribute{ + "id": dschema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "Project ID. If both id and name are omitted, the provider project_id is used.", + }, + "name": dschema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "Project name for exact lookup.", + }, + "status": dschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Project status.", + }, + "created_at": dschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Project creation timestamp.", + }, + "updated_at": dschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Project update timestamp.", + }, + }, + } +} + +func (d *projectDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(projectClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Kernel Client Type", + "Expected provider data to implement the project data source durable client contract.", + ) + return + } + + d.client = client +} + +func (d *projectDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config projectModel + 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 *projectDataSource) read(ctx context.Context, config projectModel) (projectModel, diag.Diagnostics) { + var diags diag.Diagnostics + if d.client == nil { + diags.AddError( + "Missing Kernel Client", + "The project data source was not configured with a Kernel client.", + ) + return projectModel{}, diags + } + + selector, selectorDiags := resolveProjectSelector(config.ID, config.Name) + diags.Append(selectorDiags...) + if diags.HasError() { + return projectModel{}, diags + } + + if selector == projectSelectorID { + return d.get(ctx, config.ID.ValueString()) + } + if selector == projectSelectorName { + return d.lookupName(ctx, config.Name.ValueString()) + } + + id := d.client.DefaultProjectID() + if id == "" { + diags.AddError( + "Missing Project Selector", + "Configure id, name, or provider project_id for kernel_project.", + ) + return projectModel{}, diags + } + return d.get(ctx, id) +} + +func (d *projectDataSource) get(ctx context.Context, id string) (projectModel, diag.Diagnostics) { + var diags diag.Diagnostics + project, err := d.client.GetProject(ctx, id) + if err != nil { + diags.AddError("Read Kernel Project", err.Error()) + return projectModel{}, diags + } + if project == nil { + diags.AddError("Read Kernel Project", "Kernel returned an empty project response.") + return projectModel{}, diags + } + 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 !validProjectString(project.JSON.Name.Raw(), project.JSON.Name.Valid(), project.Name) { + addInvalidProjectField(&diags, "name") + } + if !validProjectString(project.JSON.Status.Raw(), project.JSON.Status.Valid(), string(project.Status)) { + addInvalidProjectField(&diags, "status") + } + if !validProjectTime(project.JSON.CreatedAt.Raw(), project.JSON.CreatedAt.Valid(), project.CreatedAt) { + addInvalidProjectField(&diags, "created_at") + } + if !validProjectTime(project.JSON.UpdatedAt.Raw(), project.JSON.UpdatedAt.Valid(), project.UpdatedAt) { + addInvalidProjectField(&diags, "updated_at") + } + if diags.HasError() { + return projectModel{}, diags + } + + return projectModel{ + ID: types.StringValue(project.ID), + Name: types.StringValue(project.Name), + Status: types.StringValue(string(project.Status)), + CreatedAt: types.StringValue(project.CreatedAt.Format(time.RFC3339Nano)), + 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 new file mode 100644 index 0000000..b9185cd --- /dev/null +++ b/internal/datasources/project/datasource_test.go @@ -0,0 +1,329 @@ +package project + +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 _ 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) { + if f.get == nil { + return nil, errors.New("unexpected get") + } + 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) +} + +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_project" { + t.Fatalf("TypeName = %q, want kernel_project", metadata.TypeName) + } + + var schema datasource.SchemaResponse + ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema) + for _, name := range []string{"id", "name", "status", "created_at", "updated_at"} { + if _, ok := schema.Schema.Attributes[name]; !ok { + t.Fatalf("schema missing %s attribute", name) + } + } +} + +func TestReadFallsBackToDefaultProjectID(t *testing.T) { + t.Parallel() + + var gotID string + ds := newDataSourceWithClient(fakeProjectClient{ + defaultProjectID: "project-current", + get: func(ctx context.Context, id string) (*kernel.Project, error) { + gotID = id + project := projectForTest("project-current", "Current") + return &project, nil + }, + }) + + state, diags := ds.read(context.Background(), projectModel{}) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if gotID != "project-current" { + t.Fatalf("GetProject id = %q, want project-current", gotID) + } + if state.ID.ValueString() != "project-current" { + t.Fatalf("state id = %q, want project-current", state.ID.ValueString()) + } + if state.Name.ValueString() != "Current" { + t.Fatalf("state name = %q, want Current", state.Name.ValueString()) + } +} + +func TestReadSetsTerraformState(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeProjectClient{ + list: listProjectPages(t, "Target", map[int64]kernelclient.ProjectPage{ + 0: projectPage(projectForTest("project-target", "Target")), + }), + }) + + var schemaResp datasource.SchemaResponse + ds.Schema(context.Background(), datasource.SchemaRequest{}, &schemaResp) + + req := datasource.ReadRequest{ + Config: tfsdk.Config{ + Schema: schemaResp.Schema, + Raw: projectConfigValue(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 projectModel + 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() != "project-target" { + t.Fatalf("state id = %q, want project-target", state.ID.ValueString()) + } + if state.Name.ValueString() != "Target" { + t.Fatalf("state name = %q, want Target", state.Name.ValueString()) + } + if state.Status.ValueString() != string(kernel.ProjectStatusActive) { + t.Fatalf("state status = %q, want active", state.Status.ValueString()) + } +} + +func TestReadLooksUpExactProjectName(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeProjectClient{ + list: listProjectPages(t, "Target", map[int64]kernelclient.ProjectPage{ + 0: projectPageWithNext(100, projectForTest("project-other", "Other")), + 100: projectPage(projectForTest("project-target", "Target")), + }), + }) + + state, diags := ds.read(context.Background(), projectModel{Name: types.StringValue("Target")}) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if state.ID.ValueString() != "project-target" { + t.Fatalf("state id = %q, want project-target", state.ID.ValueString()) + } +} + +func TestReadRejectsAmbiguousProjectName(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeProjectClient{ + list: listProjectPages(t, "Target", map[int64]kernelclient.ProjectPage{ + 0: projectPageWithNext(100, projectForTest("project-a", "Target")), + 100: projectPage(projectForTest("project-b", "Target")), + }), + }) + + _, diags := ds.read(context.Background(), projectModel{Name: types.StringValue("Target")}) + if !diags.HasError() { + t.Fatal("expected diagnostics for ambiguous project name") + } +} + +func TestReadRejectsInvalidProjectLookupCandidate(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), + }), + }) + + _, diags := ds.read(context.Background(), projectModel{Name: types.StringValue("Target")}) + if !diags.HasError() { + t.Fatal("expected diagnostics for invalid project lookup candidate") + } +} + +func TestReadRejectsIncompleteProjectResponse(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeProjectClient{ + get: func(ctx context.Context, id string) (*kernel.Project, error) { + project := projectForTest("project-current", "Current") + project.UpdatedAt = time.Time{} + return &project, nil + }, + }) + + _, diags := ds.read(context.Background(), projectModel{ID: types.StringValue("project-current")}) + if !diags.HasError() { + t.Fatal("expected diagnostics for incomplete project response") + } +} + +func TestReadRejectsInvalidProjectResponseField(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeProjectClient{ + get: func(ctx context.Context, id string) (*kernel.Project, error) { + project := projectForTest("project-current", "Current") + project.ID = "123" + project.JSON.ID = respjson.NewInvalidField("123") + return &project, nil + }, + }) + + _, diags := ds.read(context.Background(), projectModel{ID: types.StringValue("project-current")}) + if !diags.HasError() { + t.Fatal("expected diagnostics for invalid project response field") + } +} + +func TestReadRejectsMissingProjectSelector(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeProjectClient{}) + + _, diags := ds.read(context.Background(), projectModel{}) + if !diags.HasError() { + t.Fatal("expected diagnostics for missing project selector") + } +} + +func TestReadRejectsEmptyProjectSelectors(t *testing.T) { + t.Parallel() + + tests := map[string]projectModel{ + "empty id": { + ID: types.StringValue(""), + }, + "empty name with provider fallback": { + Name: types.StringValue(""), + }, + } + + for name, config := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + called := false + ds := newDataSourceWithClient(fakeProjectClient{ + defaultProjectID: "project-current", + get: func(ctx context.Context, id string) (*kernel.Project, error) { + called = true + return nil, errors.New("should not read project") + }, + }) + + _, diags := ds.read(context.Background(), config) + if !diags.HasError() { + t.Fatal("expected diagnostics for empty selector") + } + if called { + t.Fatal("GetProject was called for empty selector") + } + }) + } +} + +func projectForTest(id, name string) kernel.Project { + body := `{"id":"` + id + `","name":"` + name + `","status":"active","created_at":"2026-06-05T12:00:00Z","updated_at":"2026-06-05T12:00:00Z"}` + + var project kernel.Project + if err := json.Unmarshal([]byte(body), &project); err != nil { + panic(err) + } + return project +} + +func projectConfigValue(id, name tftypes.Value) tftypes.Value { + return tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "name": tftypes.String, + "status": tftypes.String, + "created_at": tftypes.String, + "updated_at": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "id": id, + "name": name, + "status": tftypes.NewValue(tftypes.String, nil), + "created_at": tftypes.NewValue(tftypes.String, nil), + "updated_at": tftypes.NewValue(tftypes.String, nil), + }, + ) +} + +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 94892f7..5e99058 100644 --- a/internal/kernelclient/client.go +++ b/internal/kernelclient/client.go @@ -2,7 +2,9 @@ package kernelclient import ( "context" + "fmt" "net/http" + "strconv" "time" kernel "github.com/kernel/kernel-go-sdk" @@ -11,6 +13,14 @@ import ( const DefaultRequestTimeout = 2 * time.Minute +const nameLookupLimit int64 = 100 + +type ProjectPage struct { + Items []kernel.Project + NextOffset int64 + HasNextPage bool +} + // Config configures the shared Kernel API clients. ProjectID is a default // only; the client never applies it implicitly. type Config struct { @@ -69,6 +79,35 @@ func (c Clients) GetProject(ctx context.Context, id string) (*kernel.Project, er return c.projects.Get(ctx, id) } +func (c Clients) ListProjectPage(ctx context.Context, query string, offset int64) (ProjectPage, error) { + var raw *http.Response + params := kernel.ProjectListParams{ + Query: kernel.String(query), + Limit: kernel.Int(nameLookupLimit), + } + if offset > 0 { + params.Offset = kernel.Int(offset) + } + + page, err := c.projects.List(ctx, params, option.WithResponseInto(&raw)) + if err != nil { + return ProjectPage{}, err + } + if page == nil { + return ProjectPage{}, fmt.Errorf("Kernel returned an empty project list response") + } + + next, ok, err := projectLookupNextOffset(raw, offset) + if err != nil { + return ProjectPage{}, err + } + return ProjectPage{ + Items: page.Items, + NextOffset: next, + HasNextPage: ok, + }, nil +} + // The remaining methods are project-scoped and take the resolved project // for each call. @@ -111,6 +150,34 @@ func noMutationRetries() option.RequestOption { return option.WithMaxRetries(0) } +func projectLookupNextOffset(raw *http.Response, current int64) (int64, bool, error) { + if raw == nil { + return 0, false, fmt.Errorf("Kernel returned an empty pagination response") + } + + value := raw.Header.Get("X-Next-Offset") + if value == "" { + return 0, false, nil + } + + next, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0, false, fmt.Errorf("invalid Kernel pagination next offset %q: %w", value, err) + } + if next <= 0 { + return 0, false, nil + } + if next <= current { + return 0, false, fmt.Errorf( + "non-advancing Kernel project pagination: current offset %d, next offset %d", + current, + next, + ) + } + + return next, 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 3732d68..5af881a 100644 --- a/internal/kernelclient/client_test.go +++ b/internal/kernelclient/client_test.go @@ -80,6 +80,71 @@ func TestClientsSendProjectHeaderOnlyWhenExplicitlyScoped(t *testing.T) { } } +func TestListProjectPageReadsItemsAndNextOffset(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"))}, + }))) + + page, err := clients.ListProjectPage(context.Background(), "Target", 0) + if err != nil { + t.Fatalf("ListProjectPage 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.HasNextPage { + t.Fatal("HasNextPage = false, want true") + } + if page.NextOffset != 100 { + t.Fatalf("NextOffset = %d, want 100", page.NextOffset) + } + + page, err = clients.ListProjectPage(context.Background(), "Target", 100) + if err != nil { + t.Fatalf("ListProjectPage 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 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") @@ -234,6 +299,12 @@ type capturedRequest struct { HasDeadline bool } +type lookupPage struct { + offset string + next string + body string +} + func recordingHTTPClient(requests *[]capturedRequest, responseBody func(*http.Request) string) *http.Client { return recordingHTTPClientWithStatus(requests, http.StatusOK, responseBody) } @@ -241,18 +312,7 @@ func recordingHTTPClient(requests *[]capturedRequest, responseBody func(*http.Re func recordingHTTPClientWithStatus(requests *[]capturedRequest, status int, responseBody func(*http.Request) string) *http.Client { return &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if requests != nil { - _, hasDeadline := req.Context().Deadline() - *requests = append(*requests, capturedRequest{ - Method: req.Method, - Path: req.URL.Path, - Host: req.URL.Host, - Authorization: req.Header.Get("Authorization"), - ProjectID: req.Header.Get("X-Kernel-Project-Id"), - RetryCount: req.Header.Get("X-Stainless-Retry-Count"), - HasDeadline: hasDeadline, - }) - } + captureRequest(requests, req) return &http.Response{ StatusCode: status, @@ -262,3 +322,86 @@ func recordingHTTPClientWithStatus(requests *[]capturedRequest, status int, resp }), } } + +func recordingHTTPClientWithHeaders(requests *[]capturedRequest, response func(*http.Request) (string, http.Header)) *http.Client { + return &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + captureRequest(requests, req) + + body, header := response(req) + if header == nil { + header = http.Header{} + } + if header.Get("Content-Type") == "" { + header.Set("Content-Type", "application/json") + } + + return &http.Response{ + StatusCode: http.StatusOK, + Header: header, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }), + } +} + +func captureRequest(requests *[]capturedRequest, req *http.Request) { + if requests == nil { + return + } + + _, hasDeadline := req.Context().Deadline() + *requests = append(*requests, capturedRequest{ + Method: req.Method, + Path: req.URL.Path, + Host: req.URL.Host, + Authorization: req.Header.Get("Authorization"), + ProjectID: req.Header.Get("X-Kernel-Project-Id"), + RetryCount: req.Header.Get("X-Stainless-Retry-Count"), + HasDeadline: hasDeadline, + }) +} + +func nextOffset(offset string) http.Header { + if offset == "" { + return nil + } + return http.Header{"X-Next-Offset": []string{offset}} +} + +func projectLookupHTTPClient(t *testing.T, requests *[]capturedRequest, wantQuery string, pages []lookupPage) *http.Client { + t.Helper() + + byOffset := make(map[string]lookupPage, len(pages)) + for _, page := range pages { + byOffset[page.offset] = page + } + + 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 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) + } + + offset := req.URL.Query().Get("offset") + page, ok := byOffset[offset] + if !ok { + t.Fatalf("unexpected offset: %q", offset) + } + + return page.body, nextOffset(page.next) + }) +} + +func projectListPage(projects ...string) string { + return "[" + strings.Join(projects, ",") + "]" +} + +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"}` +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 22bb878..0a45fde 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/project" "github.com/kernel/terraform-provider-kernel/internal/kernelclient" "github.com/kernel/terraform-provider-kernel/internal/resources/browserpool" ) @@ -81,5 +82,7 @@ func (p *kernelProvider) Resources(ctx context.Context) []func() resource.Resour } func (p *kernelProvider) DataSources(ctx context.Context) []func() datasource.DataSource { - return nil + return []func() datasource.DataSource{ + project.NewDataSource, + } } diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 98e482f..ea57ec5 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/hashicorp/terraform-plugin-framework/datasource" tfprovider "github.com/hashicorp/terraform-plugin-framework/provider" providerschema "github.com/hashicorp/terraform-plugin-framework/provider/schema" tfresource "github.com/hashicorp/terraform-plugin-framework/resource" @@ -47,8 +48,26 @@ func TestProviderRegistersBrowserPoolResource(t *testing.T) { t.Fatalf("resource TypeName = %q, want kernel_browser_pool", resp.TypeName) } - if dataSources := p.DataSources(context.Background()); len(dataSources) != 0 { - t.Fatalf("DataSources length = %d, want 0", len(dataSources)) +} + +func TestProviderRegistersDataSources(t *testing.T) { + t.Parallel() + + p := provider.New("test")() + + dataSources := p.DataSources(context.Background()) + if len(dataSources) != 1 { + t.Fatalf("DataSources length = %d, want 1", 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) } }