From 13d321607c01ac3a9418b9a73500e74f7a6d63e1 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:52:04 -0400 Subject: [PATCH] Add durable app lookup data source --- README.md | 1 + docs/data-sources/app.md | 33 ++ examples/README.md | 2 +- examples/lookups/main.tf | 26 +- internal/datasources/app/datasource.go | 312 ++++++++++++++++ internal/datasources/app/datasource_test.go | 385 ++++++++++++++++++++ internal/kernelclient/client.go | 34 ++ internal/kernelclient/client_test.go | 58 +++ internal/provider/provider.go | 2 + internal/provider/provider_test.go | 6 +- 10 files changed, 851 insertions(+), 8 deletions(-) create mode 100644 docs/data-sources/app.md create mode 100644 internal/datasources/app/datasource.go create mode 100644 internal/datasources/app/datasource_test.go diff --git a/README.md b/README.md index 0445ea6..f4d321c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Resources: Data sources: +- `kernel_app` - `kernel_project` - `kernel_profile` - `kernel_proxy` diff --git a/docs/data-sources/app.md b/docs/data-sources/app.md new file mode 100644 index 0000000..ed9d3ac --- /dev/null +++ b/docs/data-sources/app.md @@ -0,0 +1,33 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "kernel_app Data Source - Kernel" +subcategory: "" +description: |- + Lookup a running Kernel app version by exact app name and version. +--- + +# kernel_app (Data Source) + +Lookup a running Kernel app version by exact app name and version. + + + + +## Schema + +### Required + +- `app_name` (String) Exact app name. +- `version` (String) Exact app version label. + +### Optional + +- `project_id` (String) Project to look the app up in. Defaults to the provider `project_id`; when neither is set, the API key's project binding determines the project. + +### Read-Only + +- `actions` (Set of String) Action names available on this app version. +- `deployment_id` (String) Deployment backing this app version. +- `env_var_keys` (Set of String) Environment variable names configured for this app version. Values are never exposed. +- `id` (String) Canonical app-version ID. +- `region` (String) Deployment region. diff --git a/examples/README.md b/examples/README.md index 49badd6..73e0f55 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,6 +23,6 @@ Use local development overrides while the provider is unreleased. See the root [ - [basic-browser-pool](basic-browser-pool) creates a minimal durable browser pool. - [design-preview-browser-pool](design-preview-browser-pool) shows a browser pool shaped for repeated design-preview checks without modeling the browser sessions themselves. - [extension](extension) uploads an immutable extension archive and tracks exact content changes with `filesha256`. -- [lookups](lookups) shows read-only project, profile, proxy, and extension data sources. +- [lookups](lookups) shows read-only app, project, profile, proxy, and extension data sources. - [project](project) creates a durable Kernel project with an explicit unique name. - [project-scoped-browser-pool](project-scoped-browser-pool) places a browser pool in an explicit project, overriding the provider-level `project_id` default. diff --git a/examples/lookups/main.tf b/examples/lookups/main.tf index ae3684c..6ccc2b4 100644 --- a/examples/lookups/main.tf +++ b/examples/lookups/main.tf @@ -28,6 +28,16 @@ variable "extension_name" { description = "Existing Kernel extension name for exact lookup." } +variable "app_name" { + type = string + description = "Existing running Kernel app name for exact lookup." +} + +variable "app_version" { + type = string + description = "Existing running Kernel app version for exact lookup." +} + data "kernel_project" "selected" { name = var.project_name } @@ -47,11 +57,19 @@ data "kernel_extension" "selected" { project_id = data.kernel_project.selected.id } +data "kernel_app" "selected" { + app_name = var.app_name + version = var.app_version + project_id = data.kernel_project.selected.id +} + output "kernel_ids" { value = { - project_id = data.kernel_project.selected.id - profile_id = data.kernel_profile.selected.id - proxy_id = data.kernel_proxy.selected.id - extension_id = data.kernel_extension.selected.id + project_id = data.kernel_project.selected.id + profile_id = data.kernel_profile.selected.id + proxy_id = data.kernel_proxy.selected.id + extension_id = data.kernel_extension.selected.id + app_id = data.kernel_app.selected.id + deployment_id = data.kernel_app.selected.deployment_id } } diff --git a/internal/datasources/app/datasource.go b/internal/datasources/app/datasource.go new file mode 100644 index 0000000..ec96b8f --- /dev/null +++ b/internal/datasources/app/datasource.go @@ -0,0 +1,312 @@ +package app + +import ( + "context" + "encoding/json" + "reflect" + "sort" + "strings" + + "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 = (*appDataSource)(nil) + _ datasource.DataSourceWithConfigure = (*appDataSource)(nil) +) + +type appClient interface { + DefaultProjectID() string + ListAppPage(context.Context, string, string, string, int64) (kernelclient.AppPage, error) +} + +type appDataSource struct { + client appClient +} + +type appModel struct { + ID types.String `tfsdk:"id"` + AppName types.String `tfsdk:"app_name"` + Version types.String `tfsdk:"version"` + ProjectID types.String `tfsdk:"project_id"` + DeploymentID types.String `tfsdk:"deployment_id"` + Region types.String `tfsdk:"region"` + Actions types.Set `tfsdk:"actions"` + EnvVarKeys types.Set `tfsdk:"env_var_keys"` +} + +func NewDataSource() datasource.DataSource { + return &appDataSource{} +} + +func newDataSourceWithClient(client appClient) *appDataSource { + return &appDataSource{client: client} +} + +func (d *appDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_app" +} + +func (d *appDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = dschema.Schema{ + MarkdownDescription: "Lookup a running Kernel app version by exact app name and version.", + Attributes: map[string]dschema.Attribute{ + "id": dschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Canonical app-version ID.", + }, + "app_name": dschema.StringAttribute{ + Required: true, + MarkdownDescription: "Exact app name.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "version": dschema.StringAttribute{ + Required: true, + MarkdownDescription: "Exact app version label.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "project_id": dschema.StringAttribute{ + Optional: true, + MarkdownDescription: "Project to look the app 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), + }, + }, + "deployment_id": dschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Deployment backing this app version.", + }, + "region": dschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Deployment region.", + }, + "actions": dschema.SetAttribute{ + Computed: true, + ElementType: types.StringType, + MarkdownDescription: "Action names available on this app version.", + }, + "env_var_keys": dschema.SetAttribute{ + Computed: true, + ElementType: types.StringType, + MarkdownDescription: "Environment variable names configured for this app version. Values are never exposed.", + }, + }, + } +} + +func (d *appDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(appClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Kernel Client Type", + "Expected provider data to implement the app data source durable client contract.", + ) + return + } + + d.client = client +} + +func (d *appDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config appModel + 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 *appDataSource) read(ctx context.Context, config appModel) (appModel, diag.Diagnostics) { + var diags diag.Diagnostics + if d.client == nil { + diags.AddError("Missing Kernel Client", "The app data source was not configured with a Kernel client.") + return appModel{}, diags + } + if config.AppName.IsNull() || config.AppName.IsUnknown() || config.AppName.ValueString() == "" { + diags.AddError("Invalid Kernel App Name", "app_name must be a known, non-empty string.") + } + if config.Version.IsNull() || config.Version.IsUnknown() || config.Version.ValueString() == "" { + diags.AddError("Invalid Kernel App Version", "version must be a known, non-empty string.") + } + if diags.HasError() { + return appModel{}, diags + } + + projectID := projectscope.ResolveDataSource(&diags, config.ProjectID, d.client.DefaultProjectID()) + if diags.HasError() { + return appModel{}, diags + } + + match, count := d.findExact(ctx, projectID, config.AppName.ValueString(), config.Version.ValueString(), &diags) + if diags.HasError() { + return appModel{}, diags + } + switch count { + case 0: + diags.AddError("Lookup Kernel App", "No running Kernel app found with the configured exact name and version.") + return appModel{}, diags + case 1: + state, flattenDiags := flattenApp(ctx, *match) + diags.Append(flattenDiags...) + if diags.HasError() { + return appModel{}, diags + } + state.ProjectID = config.ProjectID + return state, diags + default: + diags.AddError("Ambiguous Kernel App", "Found multiple running Kernel apps with the configured exact name and version.") + return appModel{}, diags + } +} + +func (d *appDataSource) findExact(ctx context.Context, projectID, appName, version string, diags *diag.Diagnostics) (*kernel.AppListResponse, int) { + var match *kernel.AppListResponse + count := 0 + offset := int64(0) + seen := map[string]bool{} + + for { + page, err := d.client.ListAppPage(ctx, projectID, appName, version, offset) + if err != nil { + projectscope.AddError(diags, "Lookup Kernel App", projectID, err) + return nil, 0 + } + for _, app := range page.Items { + if app.AppName != appName || app.Version != version || seen[app.ID] { + continue + } + if !datasources.ValidResponseString(app.JSON.ID.Raw(), app.JSON.ID.Valid(), app.ID) { + datasources.AddInvalidResponseField(diags, "App", "id") + return nil, 0 + } + if !datasources.ValidResponseString(app.JSON.AppName.Raw(), app.JSON.AppName.Valid(), app.AppName) { + datasources.AddInvalidResponseField(diags, "App", "app_name") + return nil, 0 + } + if !datasources.ValidResponseString(app.JSON.Version.Raw(), app.JSON.Version.Valid(), app.Version) { + datasources.AddInvalidResponseField(diags, "App", "version") + return nil, 0 + } + seen[app.ID] = true + count++ + if match == nil { + matched := app + match = &matched + } + } + if !page.HasNextPage { + break + } + offset = page.NextOffset + } + + return match, count +} + +func flattenApp(ctx context.Context, app kernel.AppListResponse) (appModel, diag.Diagnostics) { + var diags diag.Diagnostics + if !datasources.ValidResponseString(app.JSON.ID.Raw(), app.JSON.ID.Valid(), app.ID) { + datasources.AddInvalidResponseField(&diags, "App", "id") + } + if !datasources.ValidResponseString(app.JSON.AppName.Raw(), app.JSON.AppName.Valid(), app.AppName) { + datasources.AddInvalidResponseField(&diags, "App", "app_name") + } + if !datasources.ValidResponseString(app.JSON.Version.Raw(), app.JSON.Version.Valid(), app.Version) { + datasources.AddInvalidResponseField(&diags, "App", "version") + } + if !datasources.ValidResponseString(app.JSON.Deployment.Raw(), app.JSON.Deployment.Valid(), app.Deployment) { + datasources.AddInvalidResponseField(&diags, "App", "deployment") + } + if !datasources.ValidResponseString(app.JSON.Region.Raw(), app.JSON.Region.Valid(), string(app.Region)) { + datasources.AddInvalidResponseField(&diags, "App", "region") + } + if !validAppActions(app) { + datasources.AddInvalidResponseField(&diags, "App", "actions") + } + if !validAppEnvVars(app) { + datasources.AddInvalidResponseField(&diags, "App", "env_vars") + } + if diags.HasError() { + return appModel{}, diags + } + + actionNames := make([]string, 0, len(app.Actions)) + seenActions := make(map[string]bool, len(app.Actions)) + for _, action := range app.Actions { + if !datasources.ValidResponseString(action.JSON.Name.Raw(), action.JSON.Name.Valid(), action.Name) || seenActions[action.Name] { + datasources.AddInvalidResponseField(&diags, "App", "actions") + return appModel{}, diags + } + seenActions[action.Name] = true + actionNames = append(actionNames, action.Name) + } + sort.Strings(actionNames) + + envVarKeys := make([]string, 0, len(app.EnvVars)) + for key := range app.EnvVars { + envVarKeys = append(envVarKeys, key) + } + sort.Strings(envVarKeys) + + actions, actionDiags := types.SetValueFrom(ctx, types.StringType, actionNames) + diags.Append(actionDiags...) + envVars, envVarDiags := types.SetValueFrom(ctx, types.StringType, envVarKeys) + diags.Append(envVarDiags...) + if diags.HasError() { + return appModel{}, diags + } + + return appModel{ + ID: types.StringValue(app.ID), + AppName: types.StringValue(app.AppName), + Version: types.StringValue(app.Version), + DeploymentID: types.StringValue(app.Deployment), + Region: types.StringValue(string(app.Region)), + Actions: actions, + EnvVarKeys: envVars, + }, diags +} + +func validAppActions(app kernel.AppListResponse) bool { + if !datasources.FieldPresent(app.JSON.Actions.Raw()) || !app.JSON.Actions.Valid() { + return false + } + var decoded []json.RawMessage + return json.Unmarshal([]byte(app.JSON.Actions.Raw()), &decoded) == nil && len(decoded) == len(app.Actions) +} + +func validAppEnvVars(app kernel.AppListResponse) bool { + raw := strings.TrimSpace(app.JSON.EnvVars.Raw()) + if raw == "null" { + return len(app.EnvVars) == 0 + } + if !datasources.FieldPresent(raw) || !app.JSON.EnvVars.Valid() { + return false + } + var decoded map[string]string + return json.Unmarshal([]byte(raw), &decoded) == nil && reflect.DeepEqual(decoded, app.EnvVars) +} diff --git a/internal/datasources/app/datasource_test.go b/internal/datasources/app/datasource_test.go new file mode 100644 index 0000000..ebe2493 --- /dev/null +++ b/internal/datasources/app/datasource_test.go @@ -0,0 +1,385 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/terraform-provider-kernel/internal/kernelclient" +) + +var _ appClient = kernelclient.Clients{} + +type fakeAppClient struct { + defaultProjectID string + list func(context.Context, string, string, string, int64) (kernelclient.AppPage, error) +} + +func (f fakeAppClient) DefaultProjectID() string { + return f.defaultProjectID +} + +func (f fakeAppClient) ListAppPage(ctx context.Context, projectID, appName, version string, offset int64) (kernelclient.AppPage, error) { + if f.list == nil { + return kernelclient.AppPage{}, errors.New("unexpected app list") + } + return f.list(ctx, projectID, appName, version, 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_app" { + t.Fatalf("TypeName = %q, want kernel_app", metadata.TypeName) + } + + var schema datasource.SchemaResponse + ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema) + for _, name := range []string{"id", "app_name", "version", "project_id", "deployment_id", "region", "actions", "env_var_keys"} { + if _, ok := schema.Schema.Attributes[name]; !ok { + t.Fatalf("schema missing %s", name) + } + } + for _, name := range []string{"env_vars", "input_schema", "output_schema", "status", "logs"} { + if _, ok := schema.Schema.Attributes[name]; ok { + t.Fatalf("schema must not expose %s", name) + } + } +} + +func TestReadSetsTerraformStateWithoutSecretValues(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeAppClient{ + list: listAppPages(t, "demo", "v1", map[int64]kernelclient.AppPage{ + 0: appPage(appForTest(t, "app-version-1", "demo", "v1")), + }), + }) + + var schemaResp datasource.SchemaResponse + ds.Schema(context.Background(), datasource.SchemaRequest{}, &schemaResp) + req := datasource.ReadRequest{ + Config: tfsdk.Config{ + Schema: schemaResp.Schema, + Raw: appConfigValue("demo", "v1"), + }, + } + 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 appModel + 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() != "app-version-1" || state.DeploymentID.ValueString() != "deployment-1" { + t.Fatalf("state ids = %q/%q", state.ID.ValueString(), state.DeploymentID.ValueString()) + } + assertStringSet(t, state.Actions, []string{"health", "run"}) + assertStringSet(t, state.EnvVarKeys, []string{"API_TOKEN", "REGION"}) +} + +func TestReadResolvesProjectScope(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + configProjectID types.String + defaultProjectID string + wantProjectID string + }{ + "explicit wins": { + configProjectID: types.StringValue("project_explicit"), + defaultProjectID: "project_default", + wantProjectID: "project_explicit", + }, + "provider default": { + configProjectID: types.StringNull(), + defaultProjectID: "project_default", + wantProjectID: "project_default", + }, + "api key binding": { + configProjectID: types.StringNull(), + wantProjectID: "", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + var gotProjectID string + ds := newDataSourceWithClient(fakeAppClient{ + defaultProjectID: test.defaultProjectID, + list: func(ctx context.Context, projectID, appName, version string, offset int64) (kernelclient.AppPage, error) { + gotProjectID = projectID + return appPage(appForTest(t, "app-version-1", appName, version)), nil + }, + }) + state, diags := ds.read(context.Background(), appModel{ + AppName: types.StringValue("demo"), + Version: types.StringValue("v1"), + ProjectID: test.configProjectID, + }) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if gotProjectID != test.wantProjectID { + t.Fatalf("project = %q, want %q", gotProjectID, test.wantProjectID) + } + if !state.ProjectID.Equal(test.configProjectID) { + t.Fatalf("state project_id = %v, want %v", state.ProjectID, test.configProjectID) + } + }) + } +} + +func TestReadScansPagesAndDeduplicatesByID(t *testing.T) { + t.Parallel() + + target := appForTest(t, "app-version-1", "demo", "v1") + ds := newDataSourceWithClient(fakeAppClient{ + list: listAppPages(t, "demo", "v1", map[int64]kernelclient.AppPage{ + 0: appPageWithNext(100, appForTest(t, "other", "other", "v1"), target), + 100: appPage(target), + }), + }) + + state, diags := ds.read(context.Background(), validConfig()) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if state.ID.ValueString() != "app-version-1" { + t.Fatalf("id = %q, want app-version-1", state.ID.ValueString()) + } +} + +func TestReadDiagnosesMissingAndAmbiguousMatches(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + pages map[int64]kernelclient.AppPage + want string + }{ + "missing": { + pages: map[int64]kernelclient.AppPage{0: appPage()}, + want: "No running Kernel app", + }, + "ambiguous": { + pages: map[int64]kernelclient.AppPage{0: appPage( + appForTest(t, "app-version-1", "demo", "v1"), + appForTest(t, "app-version-2", "demo", "v1"), + )}, + want: "multiple running Kernel apps", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + ds := newDataSourceWithClient(fakeAppClient{list: listAppPages(t, "demo", "v1", test.pages)}) + _, diags := ds.read(context.Background(), validConfig()) + if !diags.HasError() || !containsDiagnostic(diags, test.want) { + t.Fatalf("diagnostics = %v, want %q", diags, test.want) + } + }) + } +} + +func TestFlattenAppRejectsMalformedRequiredFields(t *testing.T) { + t.Parallel() + + valid := map[string]any{ + "id": "app-version-1", + "app_name": "demo", + "version": "v1", + "region": "aws.us-east-1a", + "deployment": "deployment-1", + "actions": []any{map[string]any{"name": "run"}}, + "env_vars": map[string]string{"TOKEN": ""}, + } + + for _, field := range []string{"id", "app_name", "version", "region", "deployment", "actions", "env_vars"} { + field := field + t.Run("missing "+field, func(t *testing.T) { + t.Parallel() + payload := cloneMap(valid) + delete(payload, field) + _, diags := flattenApp(context.Background(), appFromPayload(t, payload)) + if !diags.HasError() { + t.Fatalf("expected diagnostics for missing %s", field) + } + }) + } + + t.Run("duplicate action names", func(t *testing.T) { + t.Parallel() + payload := cloneMap(valid) + payload["actions"] = []any{map[string]any{"name": "run"}, map[string]any{"name": "run"}} + _, diags := flattenApp(context.Background(), appFromPayload(t, payload)) + if !diags.HasError() { + t.Fatal("expected diagnostics for duplicate action names") + } + }) + + t.Run("null environment variables", func(t *testing.T) { + t.Parallel() + payload := cloneMap(valid) + payload["env_vars"] = nil + state, diags := flattenApp(context.Background(), appFromPayload(t, payload)) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + assertStringSet(t, state.EnvVarKeys, nil) + }) +} + +func TestReadRejectsInvalidConfiguration(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeAppClient{}) + for name, config := range map[string]appModel{ + "missing app name": {AppName: types.StringNull(), Version: types.StringValue("v1")}, + "unknown version": {AppName: types.StringValue("demo"), Version: types.StringUnknown()}, + "unknown project": {AppName: types.StringValue("demo"), Version: types.StringValue("v1"), ProjectID: types.StringUnknown()}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, diags := ds.read(context.Background(), config) + if !diags.HasError() { + t.Fatal("expected diagnostics") + } + }) + } +} + +func validConfig() appModel { + return appModel{AppName: types.StringValue("demo"), Version: types.StringValue("v1"), ProjectID: types.StringNull()} +} + +func appForTest(t *testing.T, id, appName, version string) kernel.AppListResponse { + t.Helper() + return appFromPayload(t, map[string]any{ + "id": id, + "app_name": appName, + "version": version, + "region": "aws.us-east-1a", + "deployment": "deployment-1", + "actions": []any{ + map[string]any{"name": "run", "input_schema": map[string]any{"type": "object"}, "output_schema": nil}, + map[string]any{"name": "health", "input_schema": nil, "output_schema": map[string]any{"type": "boolean"}}, + }, + "env_vars": map[string]string{"API_TOKEN": "must-not-enter-state", "REGION": "us-east"}, + }) +} + +func appFromPayload(t *testing.T, payload map[string]any) kernel.AppListResponse { + t.Helper() + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal app: %v", err) + } + var app kernel.AppListResponse + if err := json.Unmarshal(raw, &app); err != nil { + t.Fatalf("unmarshal app: %v", err) + } + return app +} + +func cloneMap(source map[string]any) map[string]any { + cloned := make(map[string]any, len(source)) + for key, value := range source { + cloned[key] = value + } + return cloned +} + +func listAppPages(t *testing.T, appName, version string, pages map[int64]kernelclient.AppPage) func(context.Context, string, string, string, int64) (kernelclient.AppPage, error) { + t.Helper() + return func(ctx context.Context, projectID, gotName, gotVersion string, offset int64) (kernelclient.AppPage, error) { + if gotName != appName || gotVersion != version { + t.Fatalf("lookup = %q/%q, want %q/%q", gotName, gotVersion, appName, version) + } + page, ok := pages[offset] + if !ok { + t.Fatalf("unexpected app page offset %d", offset) + } + return page, nil + } +} + +func appPage(apps ...kernel.AppListResponse) kernelclient.AppPage { + return kernelclient.AppPage{Items: apps} +} + +func appPageWithNext(next int64, apps ...kernel.AppListResponse) kernelclient.AppPage { + return kernelclient.AppPage{Items: apps, NextOffset: next, HasNextPage: true} +} + +func appConfigValue(appName, version string) tftypes.Value { + setType := tftypes.Set{ElementType: tftypes.String} + return tftypes.NewValue( + tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "app_name": tftypes.String, + "version": tftypes.String, + "project_id": tftypes.String, + "deployment_id": tftypes.String, + "region": tftypes.String, + "actions": setType, + "env_var_keys": setType, + }}, + map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, nil), + "app_name": tftypes.NewValue(tftypes.String, appName), + "version": tftypes.NewValue(tftypes.String, version), + "project_id": tftypes.NewValue(tftypes.String, nil), + "deployment_id": tftypes.NewValue(tftypes.String, nil), + "region": tftypes.NewValue(tftypes.String, nil), + "actions": tftypes.NewValue(setType, nil), + "env_var_keys": tftypes.NewValue(setType, nil), + }, + ) +} + +func assertStringSet(t *testing.T, set types.Set, want []string) { + t.Helper() + var got []string + diags := set.ElementsAs(context.Background(), &got, false) + if diags.HasError() { + t.Fatalf("decode set: %v", diags) + } + if len(got) != len(want) { + t.Fatalf("set = %v, want %v", got, want) + } + wanted := make(map[string]bool, len(want)) + for _, value := range want { + wanted[value] = true + } + for _, value := range got { + if !wanted[value] { + t.Fatalf("set = %v, want %v", got, want) + } + } +} + +func containsDiagnostic(diags diag.Diagnostics, text string) bool { + for _, diagnostic := range diags { + if strings.Contains(diagnostic.Detail(), text) { + return true + } + } + return false +} diff --git a/internal/kernelclient/client.go b/internal/kernelclient/client.go index 643353a..9aa1911 100644 --- a/internal/kernelclient/client.go +++ b/internal/kernelclient/client.go @@ -23,6 +23,7 @@ type Page[T any] struct { type ProfilePage = Page[kernel.Profile] type ProxyPage = Page[kernel.ProxyListResponse] +type AppPage = Page[kernel.AppListResponse] // Config configures the shared Kernel API clients. ProjectID is a default // only; the client never applies it implicitly. @@ -38,6 +39,7 @@ type Clients struct { projects kernel.ProjectService profiles kernel.ProfileService proxies kernel.ProxyService + apps kernel.AppService extensions kernel.ExtensionService browserPools kernel.BrowserPoolService } @@ -67,6 +69,7 @@ func New(config Config, opts ...Option) Clients { projects: kernel.NewProjectService(requestOpts...), profiles: kernel.NewProfileService(requestOpts...), proxies: kernel.NewProxyService(requestOpts...), + apps: kernel.NewAppService(requestOpts...), extensions: kernel.NewExtensionService(requestOpts...), browserPools: kernel.NewBrowserPoolService(requestOpts...), } @@ -130,6 +133,37 @@ func (c Clients) ListProxyPage(ctx context.Context, projectID string, offset int HasNextPage: ok, }, nil } + +func (c Clients) ListAppPage(ctx context.Context, projectID, appName, version string, offset int64) (AppPage, error) { + var raw *http.Response + params := kernel.AppListParams{ + AppName: kernel.String(appName), + Version: kernel.String(version), + Limit: kernel.Int(nameLookupLimit), + } + if offset > 0 { + params.Offset = kernel.Int(offset) + } + + page, err := c.apps.List(ctx, params, scope(projectID, option.WithResponseInto(&raw))...) + if err != nil { + return AppPage{}, err + } + if page == nil { + return AppPage{}, fmt.Errorf("Kernel returned an empty app list response") + } + + next, ok, err := lookupNextOffset(raw, offset, "app") + if err != nil { + return AppPage{}, err + } + return AppPage{ + Items: page.Items, + NextOffset: next, + HasNextPage: ok, + }, nil +} + func (c Clients) GetProfile(ctx context.Context, projectID, idOrName string) (*kernel.Profile, error) { return c.profiles.Get(ctx, idOrName, scope(projectID)...) } diff --git a/internal/kernelclient/client_test.go b/internal/kernelclient/client_test.go index 05e603a..b98cbc9 100644 --- a/internal/kernelclient/client_test.go +++ b/internal/kernelclient/client_test.go @@ -237,6 +237,60 @@ func TestListProxyPageReadsItemsAndNextOffset(t *testing.T) { } } +func TestListAppPageUsesExactFiltersAndPagination(t *testing.T) { + t.Parallel() + + var requests []capturedRequest + clients := New(Config{ + APIKey: "test-api-key", + BaseURL: "https://api.example", + }, WithHTTPClient(recordingHTTPClientWithHeaders(&requests, func(req *http.Request) (string, http.Header) { + if req.URL.Path != "/apps" { + t.Fatalf("path = %q, want /apps", req.URL.Path) + } + if got, want := req.URL.Query().Get("app_name"), "demo"; got != want { + t.Fatalf("app_name = %q, want %q", got, want) + } + if got, want := req.URL.Query().Get("version"), "v1"; got != want { + t.Fatalf("version = %q, want %q", got, want) + } + if got, want := req.URL.Query().Get("limit"), "100"; got != want { + t.Fatalf("limit = %q, want %q", got, want) + } + if req.URL.Query().Get("offset") == "" { + return appListPage("app-version-1"), http.Header{"X-Next-Offset": []string{"100"}, "X-Has-More": []string{"true"}} + } + if got, want := req.URL.Query().Get("offset"), "100"; got != want { + t.Fatalf("offset = %q, want %q", got, want) + } + return appListPage("app-version-2"), http.Header{"X-Has-More": []string{"false"}} + }))) + + page, err := clients.ListAppPage(context.Background(), "project_123", "demo", "v1", 0) + if err != nil { + t.Fatalf("ListAppPage returned error: %v", err) + } + if len(page.Items) != 1 || page.Items[0].ID != "app-version-1" { + t.Fatalf("items = %#v, want app-version-1", page.Items) + } + if !page.HasNextPage || page.NextOffset != 100 { + t.Fatalf("page continuation = %v/%d, want true/100", page.HasNextPage, page.NextOffset) + } + + page, err = clients.ListAppPage(context.Background(), "project_123", "demo", "v1", 100) + if err != nil { + t.Fatalf("ListAppPage second page returned error: %v", err) + } + if len(page.Items) != 1 || page.Items[0].ID != "app-version-2" || page.HasNextPage { + t.Fatalf("second page = %#v, want terminal app-version-2", page) + } + for index, request := range requests { + if got, want := request.ProjectID, "project_123"; got != want { + t.Fatalf("request %d project = %q, want %q", index, got, want) + } + } +} + func TestGetExtensionResolvesByIDOrNameWithinProject(t *testing.T) { t.Parallel() @@ -474,6 +528,10 @@ func TestClientsDoNotExposeExtensionArchiveMethods(t *testing.T) { } } +func appListPage(id string) string { + return `[{"id":"` + id + `","app_name":"demo","version":"v1","region":"aws.us-east-1a","deployment":"deployment-1","actions":[],"env_vars":{}}]` +} + type capturedRequest struct { Method string Path string diff --git a/internal/provider/provider.go b/internal/provider/provider.go index b43f0de..cfc1ec8 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" + appdatasource "github.com/kernel/terraform-provider-kernel/internal/datasources/app" browserpooldatasource "github.com/kernel/terraform-provider-kernel/internal/datasources/browserpool" extensiondatasource "github.com/kernel/terraform-provider-kernel/internal/datasources/extension" "github.com/kernel/terraform-provider-kernel/internal/datasources/profile" @@ -91,6 +92,7 @@ func (p *kernelProvider) Resources(ctx context.Context) []func() resource.Resour func (p *kernelProvider) DataSources(ctx context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{ + appdatasource.NewDataSource, browserpooldatasource.NewDataSource, projectdatasource.NewDataSource, profile.NewDataSource, diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 92073f8..b6f2294 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -62,8 +62,8 @@ func TestProviderRegistersDataSources(t *testing.T) { p := provider.New("test")() dataSources := p.DataSources(context.Background()) - if len(dataSources) != 5 { - t.Fatalf("DataSources length = %d, want 5", len(dataSources)) + if len(dataSources) != 6 { + t.Fatalf("DataSources length = %d, want 6", len(dataSources)) } got := make(map[string]bool, len(dataSources)) @@ -77,7 +77,7 @@ func TestProviderRegistersDataSources(t *testing.T) { got[resp.TypeName] = true } - for _, want := range []string{"kernel_browser_pool", "kernel_project", "kernel_profile", "kernel_proxy", "kernel_extension"} { + for _, want := range []string{"kernel_app", "kernel_browser_pool", "kernel_project", "kernel_profile", "kernel_proxy", "kernel_extension"} { if !got[want] { t.Fatalf("missing data source %s; got %v", want, got) }