From fe0cc9b10359bfb369457bcdda2bc4ef22b04d70 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:57:48 +0000 Subject: [PATCH 1/4] Add kernel_telemetry_destination resource --- .github/workflows/acceptance.yml | 3 + README.md | 3 + docs/acceptance.md | 10 +- docs/release.md | 4 +- docs/resources/telemetry_destination.md | 40 +++ examples/README.md | 1 + .../kernel_telemetry_destination/import.sh | 1 + examples/telemetry-destination/main.tf | 28 +++ go.mod | 2 +- go.sum | 4 +- internal/acctest/acctest.go | 35 +++ internal/kernelclient/client.go | 44 +++- internal/kernelclient/client_test.go | 28 +++ internal/provider/provider.go | 2 + internal/provider/provider_test.go | 6 +- .../resources/telemetrydestination/convert.go | 125 ++++++++++ .../telemetrydestination/convert_test.go | 208 ++++++++++++++++ .../resources/telemetrydestination/model.go | 11 + .../telemetrydestination/resource.go | 234 ++++++++++++++++++ .../telemetrydestination/resource_acc_test.go | 168 +++++++++++++ .../resources/telemetrydestination/schema.go | 58 +++++ 21 files changed, 992 insertions(+), 23 deletions(-) create mode 100644 docs/resources/telemetry_destination.md create mode 100644 examples/resources/kernel_telemetry_destination/import.sh create mode 100644 examples/telemetry-destination/main.tf create mode 100644 internal/resources/telemetrydestination/convert.go create mode 100644 internal/resources/telemetrydestination/convert_test.go create mode 100644 internal/resources/telemetrydestination/model.go create mode 100644 internal/resources/telemetrydestination/resource.go create mode 100644 internal/resources/telemetrydestination/resource_acc_test.go create mode 100644 internal/resources/telemetrydestination/schema.go diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 8f25767..60d07fe 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -27,6 +27,9 @@ jobs: - name: Project package: ./internal/resources/project project_id_required: false + - name: Telemetry destination + package: ./internal/resources/telemetrydestination + project_id_required: false - name: Project data source package: ./internal/datasources/project project_id_required: true diff --git a/README.md b/README.md index ca53016..344625d 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Resources: - `kernel_browser_pool` - `kernel_project` +- `kernel_telemetry_destination` Data sources: @@ -28,6 +29,7 @@ Import: - `kernel_browser_pool` by canonical browser pool ID - `kernel_project` by canonical project ID +- `kernel_telemetry_destination` by canonical telemetry destination ID ## Not Supported @@ -84,6 +86,7 @@ use the project-qualified form to import from a different project: terraform import kernel_browser_pool.example terraform import kernel_browser_pool.example / terraform import kernel_project.example +terraform import kernel_telemetry_destination.example ``` See the [first public release guide](docs/first-release.md) for the exact public diff --git a/docs/acceptance.md b/docs/acceptance.md index 86370c8..2ef9146 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -4,7 +4,7 @@ This document is the live-API release gate for the provider's selected public surface. Unit tests remain the fast default. Acceptance tests run after changes reach `main`, through explicit local opt-in, or by manual workflow dispatch. -The selected surface contains two managed resources and four read-only data +The selected surface contains three managed resources and four read-only data sources. It does not claim coverage for future or unregistered Kernel objects. ## Gate Rules @@ -32,7 +32,9 @@ export KERNEL_BASE_URL=... # optional non-production API ``` `KERNEL_PROJECT_ID` is required for the browser-pool resource and all four data -sources. The project resource is organization-scoped and does not require it. +sources. The project and telemetry destination resources are organization-scoped +and do not require it; the telemetry destination test needs an API key that is +not bound to a project. ## Matrix @@ -40,6 +42,7 @@ sources. The project resource is organization-scoped and does not require it. | --- | --- | --- | | `kernel_project` resource | `./internal/resources/project` | Create, rename with stable ID, no-drift plan, canonical-ID import, post-import no drift, delete, and HTTP 404 verification. | | `kernel_browser_pool` resource | `./internal/resources/browserpool` | Create, durable update with stable ID, opt into rebuilding and acquire an idle browser after a configuration change, no-drift plan, provider-default and explicit project scope, bare and project-qualified import, non-force delete, and HTTP 404 verification. | +| `kernel_telemetry_destination` resource | `./internal/resources/telemetrydestination` | Create with headers, update name, endpoint, description, and headers with stable ID, remove all headers, verify header names through the API, no-drift plan, canonical-ID import, delete, and HTTP 404 verification. | | `kernel_project` data source | `./internal/datasources/project` | Create a unique project fixture, read it by ID and exact name, read the provider-default project, verify durable metadata and no drift, then delete and require coded `not_found`. | | `kernel_profile` data source | `./internal/datasources/profile` | Create a durable profile fixture through the SDK, read it by ID and exact name with explicit and default project scope, verify durable metadata and no drift, then delete and require coded `not_found`. | | `kernel_proxy` data source | `./internal/datasources/proxy` | Create a managed datacenter proxy fixture through the SDK, read it by ID and exact name with explicit and default project scope, verify durable masked metadata and no drift, then delete and require coded `not_found`. | @@ -61,13 +64,14 @@ Run packages independently for fast failure isolation: ```sh go test -count=1 -timeout=30m -v ./internal/resources/project -run TestAcc go test -count=1 -timeout=30m -v ./internal/resources/browserpool -run TestAcc +go test -count=1 -timeout=30m -v ./internal/resources/telemetrydestination -run TestAcc go test -count=1 -timeout=30m -v ./internal/datasources/project -run TestAcc go test -count=1 -timeout=30m -v ./internal/datasources/profile -run TestAcc go test -count=1 -timeout=30m -v ./internal/datasources/proxy -run TestAcc go test -count=1 -timeout=30m -v ./internal/datasources/extension -run TestAcc ``` -The `Acceptance` workflow runs the same six packages as separate matrix jobs +The `Acceptance` workflow runs the same seven packages as separate matrix jobs with `fail-fast: false` after changes reach `main` and on manual dispatch. ## Outside The Selected Surface diff --git a/docs/release.md b/docs/release.md index 8036ad2..9de640a 100644 --- a/docs/release.md +++ b/docs/release.md @@ -19,9 +19,9 @@ Use this checklist before publishing a Kernel Terraform provider version. checks each archive, and verifies checksum coverage. A snapshot skips signing and is not a publishable release. - Run the complete [selected-surface acceptance matrix](acceptance.md) with real credentials against the release commit. - - The `Acceptance` workflow runs all six packages as independent matrix jobs after changes reach `main` and supports manual dispatch. Keep live tests out of pull-request CI. + - The `Acceptance` workflow runs all seven packages as independent matrix jobs after changes reach `main` and supports manual dispatch. Keep live tests out of pull-request CI. - Process-level timeouts can bypass Go test cleanup. After an interrupted or hard-timeout run: - 1. In the Kernel dashboard or durable API, find projects, browser pools, profiles, proxies, and extensions named `kernel-tf-*` that were created during the failed workflow run. + 1. In the Kernel dashboard or durable API, find projects, browser pools, profiles, proxies, extensions, and telemetry destinations named `kernel-tf-*` that were created during the failed workflow run. 2. Delete leaked browser pools with `force=false`. If deletion conflicts with a lease, wait for the lease to end; do not force-release or recover the browser from Terraform cleanup. 3. Delete other leaked project-scoped fixtures, then delete a leaked project only after its child resources are gone and the organization still has another active project. 4. Read each canonical ID and require the expected not-found response before considering cleanup complete. diff --git a/docs/resources/telemetry_destination.md b/docs/resources/telemetry_destination.md new file mode 100644 index 0000000..bedf6f1 --- /dev/null +++ b/docs/resources/telemetry_destination.md @@ -0,0 +1,40 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "kernel_telemetry_destination Resource - Kernel" +subcategory: "" +description: |- + Kernel OTLP telemetry destination. Browser sessions reference a destination by ID or name in telemetry.export.otlp.destination to export their telemetry to it. Destinations are organization-scoped and require an organization-scoped API key. +--- + +# kernel_telemetry_destination (Resource) + +Kernel OTLP telemetry destination. Browser sessions reference a destination by ID or name in `telemetry.export.otlp.destination` to export their telemetry to it. Destinations are organization-scoped and require an organization-scoped API key. + + + + +## Schema + +### Required + +- `endpoint` (String) Base OTLP/HTTP collector endpoint, without a signal path such as `/v1/logs`. Must be a public http or https URL with no query string or fragment, for example `https://api.honeycomb.io`. +- `name` (String) Destination name. Must be unique within the organization, match `^[a-zA-Z0-9._-]{1,255}$`, and not look like an ID. + +### Optional + +- `description` (String) Destination description. +- `headers` (Map of String, Sensitive) Headers sent with each export request, typically an ingestion key. Header names are matched case-insensitively. Kernel never returns header values, so Terraform detects added or removed header names but not values changed outside Terraform. + +### Read-Only + +- `id` (String) Unique telemetry destination identifier. + +## Import + +Import is supported using the following syntax: + +The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: + +```shell +terraform import kernel_telemetry_destination.example +``` diff --git a/examples/README.md b/examples/README.md index 6f29e6e..f749132 100644 --- a/examples/README.md +++ b/examples/README.md @@ -27,3 +27,4 @@ override setup. - [lookups](lookups) shows read-only 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. +- [telemetry-destination](telemetry-destination) creates an OTLP destination that browser sessions can export telemetry to. diff --git a/examples/resources/kernel_telemetry_destination/import.sh b/examples/resources/kernel_telemetry_destination/import.sh new file mode 100644 index 0000000..93f427c --- /dev/null +++ b/examples/resources/kernel_telemetry_destination/import.sh @@ -0,0 +1 @@ +terraform import kernel_telemetry_destination.example diff --git a/examples/telemetry-destination/main.tf b/examples/telemetry-destination/main.tf new file mode 100644 index 0000000..8b61325 --- /dev/null +++ b/examples/telemetry-destination/main.tf @@ -0,0 +1,28 @@ +terraform { + required_providers { + kernel = { + source = "kernel/kernel" + } + } +} + +provider "kernel" {} + +variable "honeycomb_api_key" { + type = string + sensitive = true + description = "Honeycomb ingest key sent with each telemetry export." +} + +resource "kernel_telemetry_destination" "honeycomb" { + name = "honeycomb" + endpoint = "https://api.honeycomb.io" + description = "Browser session telemetry" + headers = { + "x-honeycomb-team" = var.honeycomb_api_key + } +} + +output "telemetry_destination_id" { + value = kernel_telemetry_destination.honeycomb.id +} diff --git a/go.mod b/go.mod index 97efed7..55e1f57 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/hashicorp/terraform-plugin-framework-validators v0.19.0 github.com/hashicorp/terraform-plugin-go v0.31.0 github.com/hashicorp/terraform-plugin-testing v1.16.0 - github.com/kernel/kernel-go-sdk v0.76.0 + github.com/kernel/kernel-go-sdk v0.99.0 ) require ( diff --git a/go.sum b/go.sum index c1fa851..b7195ef 100644 --- a/go.sum +++ b/go.sum @@ -106,8 +106,8 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= -github.com/kernel/kernel-go-sdk v0.76.0 h1:bnGTPq69M6+Qfr0Bkkl6EGqvr0dfGrCwdiEYAx3pHOc= -github.com/kernel/kernel-go-sdk v0.76.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.99.0 h1:L8P3JNwF46/oWmwhrc8BNbgK8vTUh5HlYnPXqXT4qlU= +github.com/kernel/kernel-go-sdk v0.99.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= diff --git a/internal/acctest/acctest.go b/internal/acctest/acctest.go index 52796e9..d76fe44 100644 --- a/internal/acctest/acctest.go +++ b/internal/acctest/acctest.go @@ -159,6 +159,41 @@ func cleanupProject(t testing.TB, client projectCleaner, id string) { }) } +func CleanupTelemetryDestination(t testing.TB, id string) { + t.Helper() + + cleanupTelemetryDestination(t, ClientFromEnv(), id) +} + +type telemetryDestinationCleaner interface { + DeleteTelemetryDestination(context.Context, string) error +} + +func cleanupTelemetryDestination(t testing.TB, client telemetryDestinationCleaner, id string) { + t.Helper() + + if id == "" { + return + } + if !AcceptanceEnabled() { + t.Fatalf("%s must be set to clean up Kernel acceptance test resources", EnvAcceptance) + return + } + if os.Getenv(EnvAPIKey) == "" { + t.Fatalf("%s must be set to clean up Kernel acceptance test resources", EnvAPIKey) + return + } + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) + defer cancel() + + if err := client.DeleteTelemetryDestination(ctx, id); err != nil && !IsNotFound(err) { + t.Errorf("cleanup Kernel telemetry destination %s: %v", id, err) + } + }) +} + // CleanupProfile registers a cleanup that deletes the profile from projectID; // empty means the env-configured default project. func CleanupProfile(t testing.TB, projectID, id string) { diff --git a/internal/kernelclient/client.go b/internal/kernelclient/client.go index 28edef3..b313ab8 100644 --- a/internal/kernelclient/client.go +++ b/internal/kernelclient/client.go @@ -34,12 +34,13 @@ type Config struct { } type Clients struct { - defaultProjectID string - projects kernel.ProjectService - profiles kernel.ProfileService - proxies kernel.ProxyService - extensions kernel.ExtensionService - browserPools kernel.BrowserPoolService + defaultProjectID string + projects kernel.ProjectService + profiles kernel.ProfileService + proxies kernel.ProxyService + extensions kernel.ExtensionService + browserPools kernel.BrowserPoolService + telemetryDestinations kernel.TelemetryDestinationService } type Option func(*clientOptions) @@ -63,12 +64,13 @@ func New(config Config, opts ...Option) Clients { requestOpts := requestOptions(config, clientOpts) return Clients{ - defaultProjectID: config.ProjectID, - projects: kernel.NewProjectService(requestOpts...), - profiles: kernel.NewProfileService(requestOpts...), - proxies: kernel.NewProxyService(requestOpts...), - extensions: kernel.NewExtensionService(requestOpts...), - browserPools: kernel.NewBrowserPoolService(requestOpts...), + defaultProjectID: config.ProjectID, + projects: kernel.NewProjectService(requestOpts...), + profiles: kernel.NewProfileService(requestOpts...), + proxies: kernel.NewProxyService(requestOpts...), + extensions: kernel.NewExtensionService(requestOpts...), + browserPools: kernel.NewBrowserPoolService(requestOpts...), + telemetryDestinations: kernel.NewTelemetryDestinationService(requestOpts...), } } @@ -96,6 +98,24 @@ func (c Clients) DeleteProject(ctx context.Context, id string) error { return c.projects.Delete(ctx, id, noMutationRetries()) } +// Telemetry destinations are org-scoped too. + +func (c Clients) CreateTelemetryDestination(ctx context.Context, params kernel.TelemetryDestinationNewParams) (*kernel.OtlpDestination, error) { + return c.telemetryDestinations.New(ctx, params, noMutationRetries()) +} + +func (c Clients) GetTelemetryDestination(ctx context.Context, id string) (*kernel.OtlpDestination, error) { + return c.telemetryDestinations.Get(ctx, id) +} + +func (c Clients) UpdateTelemetryDestination(ctx context.Context, id string, params kernel.TelemetryDestinationUpdateParams) (*kernel.OtlpDestination, error) { + return c.telemetryDestinations.Update(ctx, id, params, noMutationRetries()) +} + +func (c Clients) DeleteTelemetryDestination(ctx context.Context, id string) error { + return c.telemetryDestinations.Delete(ctx, id, noMutationRetries()) +} + // The remaining methods are project-scoped and take the resolved project // for each call. diff --git a/internal/kernelclient/client_test.go b/internal/kernelclient/client_test.go index 812bc0e..6c588e9 100644 --- a/internal/kernelclient/client_test.go +++ b/internal/kernelclient/client_test.go @@ -333,6 +333,34 @@ func TestMutationsDisableSDKRetriesAndUseExpectedScope(t *testing.T) { return clients.DeleteProject(ctx, "project_123") }, }, + "telemetry destination create": { + method: http.MethodPost, + path: "/telemetry/destinations", + call: func(ctx context.Context, clients Clients) error { + _, err := clients.CreateTelemetryDestination(ctx, kernel.TelemetryDestinationNewParams{ + Name: "destination", + Endpoint: "https://otlp.example", + }) + return err + }, + }, + "telemetry destination update": { + method: http.MethodPatch, + path: "/telemetry/destinations/dest_123", + call: func(ctx context.Context, clients Clients) error { + _, err := clients.UpdateTelemetryDestination(ctx, "dest_123", kernel.TelemetryDestinationUpdateParams{ + Name: kernel.String("renamed"), + }) + return err + }, + }, + "telemetry destination delete": { + method: http.MethodDelete, + path: "/telemetry/destinations/dest_123", + call: func(ctx context.Context, clients Clients) error { + return clients.DeleteTelemetryDestination(ctx, "dest_123") + }, + }, "profile create": { method: http.MethodPost, path: "/profiles", diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 5e3a9ee..1a496fa 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -14,6 +14,7 @@ import ( "github.com/kernel/terraform-provider-kernel/internal/kernelclient" "github.com/kernel/terraform-provider-kernel/internal/resources/browserpool" projectresource "github.com/kernel/terraform-provider-kernel/internal/resources/project" + "github.com/kernel/terraform-provider-kernel/internal/resources/telemetrydestination" ) var _ provider.Provider = (*kernelProvider)(nil) @@ -83,6 +84,7 @@ func (p *kernelProvider) Resources(ctx context.Context) []func() resource.Resour return []func() resource.Resource{ browserpool.NewResource, projectresource.NewResource, + telemetrydestination.NewResource, } } diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index a3fdd0d..5a41682 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -34,8 +34,8 @@ func TestProviderRegistersResources(t *testing.T) { p := provider.New("test")() resources := p.Resources(context.Background()) - if len(resources) != 2 { - t.Fatalf("Resources length = %d, want 2", len(resources)) + if len(resources) != 3 { + t.Fatalf("Resources length = %d, want 3", len(resources)) } got := make(map[string]bool, len(resources)) @@ -49,7 +49,7 @@ func TestProviderRegistersResources(t *testing.T) { got[resp.TypeName] = true } - for _, want := range []string{"kernel_browser_pool", "kernel_project"} { + for _, want := range []string{"kernel_browser_pool", "kernel_project", "kernel_telemetry_destination"} { if !got[want] { t.Fatalf("missing resource %s; got %v", want, got) } diff --git a/internal/resources/telemetrydestination/convert.go b/internal/resources/telemetrydestination/convert.go new file mode 100644 index 0000000..a240a52 --- /dev/null +++ b/internal/resources/telemetrydestination/convert.go @@ -0,0 +1,125 @@ +package telemetrydestination + +import ( + "context" + "net/textproto" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" +) + +func expandCreate(ctx context.Context, plan destinationModel) (kernel.TelemetryDestinationNewParams, diag.Diagnostics) { + params := kernel.TelemetryDestinationNewParams{ + Name: plan.Name.ValueString(), + Endpoint: plan.Endpoint.ValueString(), + } + if !plan.Description.IsNull() { + params.Description = kernel.String(plan.Description.ValueString()) + } + headers, diags := headerMap(ctx, plan.Headers) + params.Headers = headers + return params, diags +} + +// expandUpdate returns only the fields that differ between plan and state. +// Headers patch key by key on the API, so names dropped from configuration +// are sent as explicit nulls to delete them. +func expandUpdate(ctx context.Context, plan, state destinationModel) (kernel.TelemetryDestinationUpdateParams, diag.Diagnostics) { + var params kernel.TelemetryDestinationUpdateParams + if !plan.Name.Equal(state.Name) { + params.Name = kernel.String(plan.Name.ValueString()) + } + if !plan.Endpoint.Equal(state.Endpoint) { + params.Endpoint = kernel.String(plan.Endpoint.ValueString()) + } + if !plan.Description.Equal(state.Description) { + // The API stores an empty description as none. + params.Description = kernel.String(plan.Description.ValueString()) + } + + planHeaders, diags := headerMap(ctx, plan.Headers) + stateHeaders, stateDiags := headerMap(ctx, state.Headers) + diags.Append(stateDiags...) + if diags.HasError() { + return params, diags + } + + patch := map[string]*string{} + planNames := make(map[string]struct{}, len(planHeaders)) + for name, value := range planHeaders { + planNames[textproto.CanonicalMIMEHeaderKey(name)] = struct{}{} + if prior, ok := stateHeaders[name]; !ok || prior != value { + patch[name] = &value + } + } + for name := range stateHeaders { + if _, ok := planNames[textproto.CanonicalMIMEHeaderKey(name)]; !ok { + patch[name] = nil + } + } + if len(patch) > 0 { + params.SetExtraFields(map[string]any{"headers": patch}) + } + return params, diags +} + +// flatten builds state from a destination response. Kernel redacts header +// values, so values come from prior (plan or state) and only the set of +// header names is taken from the response; a name Kernel holds that prior +// lacks is recorded with an empty value so Terraform plans to correct it. +func flatten(ctx context.Context, remote kernel.OtlpDestination, prior types.Map) (destinationModel, diag.Diagnostics) { + var diags diag.Diagnostics + for field, value := range map[string]string{"id": remote.ID, "name": remote.Name, "endpoint": remote.Endpoint} { + if value == "" { + diags.AddError( + "Invalid Kernel Telemetry Destination Response", + "Kernel returned a telemetry destination with missing field "+field+".", + ) + } + } + priorHeaders, priorDiags := headerMap(ctx, prior) + diags.Append(priorDiags...) + if diags.HasError() { + return destinationModel{}, diags + } + + model := destinationModel{ + ID: types.StringValue(remote.ID), + Name: types.StringValue(remote.Name), + Endpoint: types.StringValue(remote.Endpoint), + Description: types.StringNull(), + Headers: types.MapNull(types.StringType), + } + if remote.Description != "" { + model.Description = types.StringValue(remote.Description) + } + + priorNames := make(map[string]string, len(priorHeaders)) + for name := range priorHeaders { + priorNames[textproto.CanonicalMIMEHeaderKey(name)] = name + } + headers := make(map[string]string, len(remote.Headers)) + for name := range remote.Headers { + if priorName, ok := priorNames[textproto.CanonicalMIMEHeaderKey(name)]; ok { + headers[priorName] = priorHeaders[priorName] + } else { + headers[name] = "" + } + } + if len(headers) > 0 || !prior.IsNull() { + value, mapDiags := types.MapValueFrom(ctx, types.StringType, headers) + diags.Append(mapDiags...) + model.Headers = value + } + return model, diags +} + +func headerMap(ctx context.Context, value types.Map) (map[string]string, diag.Diagnostics) { + if value.IsNull() || value.IsUnknown() { + return nil, nil + } + var headers map[string]string + diags := value.ElementsAs(ctx, &headers, false) + return headers, diags +} diff --git a/internal/resources/telemetrydestination/convert_test.go b/internal/resources/telemetrydestination/convert_test.go new file mode 100644 index 0000000..97f8c1a --- /dev/null +++ b/internal/resources/telemetrydestination/convert_test.go @@ -0,0 +1,208 @@ +package telemetrydestination + +import ( + "context" + "encoding/json" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/terraform-provider-kernel/internal/kernelclient" +) + +var _ destinationClient = kernelclient.Clients{} + +func headersValue(t *testing.T, headers map[string]string) types.Map { + t.Helper() + if headers == nil { + return types.MapNull(types.StringType) + } + elements := make(map[string]attr.Value, len(headers)) + for name, value := range headers { + elements[name] = types.StringValue(value) + } + return types.MapValueMust(types.StringType, elements) +} + +func destinationForTest(t *testing.T, raw string) kernel.OtlpDestination { + t.Helper() + var destination kernel.OtlpDestination + if err := json.Unmarshal([]byte(raw), &destination); err != nil { + t.Fatalf("unmarshal destination: %v", err) + } + return destination +} + +func model(t *testing.T, description *string, headers map[string]string) destinationModel { + t.Helper() + m := destinationModel{ + ID: types.StringValue("dest_123"), + Name: types.StringValue("honeycomb"), + Endpoint: types.StringValue("https://api.honeycomb.io"), + Description: types.StringNull(), + Headers: headersValue(t, headers), + } + if description != nil { + m.Description = types.StringValue(*description) + } + return m +} + +func marshal(t *testing.T, v json.Marshaler) string { + t.Helper() + body, err := v.MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + return string(body) +} + +func TestExpandCreateSendsConfiguredFields(t *testing.T) { + t.Parallel() + + description := "prod" + params, diags := expandCreate(context.Background(), model(t, &description, map[string]string{"x-honeycomb-team": "key"})) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + got := marshal(t, params) + want := `{"endpoint":"https://api.honeycomb.io","name":"honeycomb","description":"prod","headers":{"x-honeycomb-team":"key"}}` + if got != want { + t.Fatalf("create body = %s, want %s", got, want) + } +} + +func TestExpandCreateOmitsUnsetOptionalFields(t *testing.T) { + t.Parallel() + + params, diags := expandCreate(context.Background(), model(t, nil, nil)) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if got, want := marshal(t, params), `{"endpoint":"https://api.honeycomb.io","name":"honeycomb"}`; got != want { + t.Fatalf("create body = %s, want %s", got, want) + } +} + +func TestExpandUpdateSendsOnlyChanges(t *testing.T) { + t.Parallel() + + description := "prod" + state := model(t, &description, map[string]string{"Authorization": "old", "X-Keep": "same", "X-Drop": "gone"}) + plan := model(t, nil, map[string]string{"Authorization": "new", "X-Keep": "same", "X-Add": "added"}) + plan.Endpoint = types.StringValue("https://otlp.example") + + params, diags := expandUpdate(context.Background(), plan, state) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + got := marshal(t, params) + want := `{"description":"","endpoint":"https://otlp.example","headers":{"Authorization":"new","X-Add":"added","X-Drop":null}}` + if got != want { + t.Fatalf("update body = %s, want %s", got, want) + } +} + +func TestExpandUpdateRespellingHeaderDoesNotDeleteIt(t *testing.T) { + t.Parallel() + + state := model(t, nil, map[string]string{"authorization": "key"}) + plan := model(t, nil, map[string]string{"Authorization": "key"}) + + params, diags := expandUpdate(context.Background(), plan, state) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if got, want := marshal(t, params), `{"headers":{"Authorization":"key"}}`; got != want { + t.Fatalf("update body = %s, want %s", got, want) + } +} + +func TestExpandUpdateRemovingAllHeadersDeletesEach(t *testing.T) { + t.Parallel() + + state := model(t, nil, map[string]string{"Authorization": "key"}) + plan := model(t, nil, nil) + + params, diags := expandUpdate(context.Background(), plan, state) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if got, want := marshal(t, params), `{"headers":{"Authorization":null}}`; got != want { + t.Fatalf("update body = %s, want %s", got, want) + } +} + +func TestFlattenKeepsPriorHeaderValuesForRedactedResponse(t *testing.T) { + t.Parallel() + + remote := destinationForTest(t, `{"id":"dest_123","name":"honeycomb","endpoint":"https://api.honeycomb.io","description":"prod","headers":{"X-Honeycomb-Team":""}}`) + prior := headersValue(t, map[string]string{"x-honeycomb-team": "key"}) + + got, diags := flatten(context.Background(), remote, prior) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if !got.Headers.Equal(prior) { + t.Fatalf("headers = %v, want %v", got.Headers, prior) + } + if got, want := got.Description.ValueString(), "prod"; got != want { + t.Fatalf("description = %q, want %q", got, want) + } +} + +func TestFlattenSurfacesHeaderNameDrift(t *testing.T) { + t.Parallel() + + remote := destinationForTest(t, `{"id":"dest_123","name":"honeycomb","endpoint":"https://api.honeycomb.io","headers":{"X-Added-Outside":""}}`) + prior := headersValue(t, map[string]string{"Authorization": "key"}) + + got, diags := flatten(context.Background(), remote, prior) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + want := headersValue(t, map[string]string{"X-Added-Outside": ""}) + if !got.Headers.Equal(want) { + t.Fatalf("headers = %v, want %v", got.Headers, want) + } + if !got.Description.IsNull() { + t.Fatalf("description = %v, want null", got.Description) + } +} + +func TestFlattenKeepsNullHeadersWhenNoneStored(t *testing.T) { + t.Parallel() + + remote := destinationForTest(t, `{"id":"dest_123","name":"honeycomb","endpoint":"https://api.honeycomb.io","headers":{}}`) + + got, diags := flatten(context.Background(), remote, types.MapNull(types.StringType)) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if !got.Headers.IsNull() { + t.Fatalf("headers = %v, want null", got.Headers) + } +} + +func TestFlattenRejectsMissingFields(t *testing.T) { + t.Parallel() + + remote := destinationForTest(t, `{"id":"dest_123","name":"honeycomb","headers":{}}`) + + if _, diags := flatten(context.Background(), remote, types.MapNull(types.StringType)); !diags.HasError() { + t.Fatal("expected diagnostics for missing endpoint") + } +} + +func TestSchemaMarksHeadersSensitive(t *testing.T) { + t.Parallel() + + headers, ok := destinationSchema().Attributes["headers"] + if !ok { + t.Fatal("missing headers attribute") + } + if !headers.IsSensitive() { + t.Fatal("headers should be sensitive") + } +} diff --git a/internal/resources/telemetrydestination/model.go b/internal/resources/telemetrydestination/model.go new file mode 100644 index 0000000..cfe19a4 --- /dev/null +++ b/internal/resources/telemetrydestination/model.go @@ -0,0 +1,11 @@ +package telemetrydestination + +import "github.com/hashicorp/terraform-plugin-framework/types" + +type destinationModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Endpoint types.String `tfsdk:"endpoint"` + Description types.String `tfsdk:"description"` + Headers types.Map `tfsdk:"headers"` +} diff --git a/internal/resources/telemetrydestination/resource.go b/internal/resources/telemetrydestination/resource.go new file mode 100644 index 0000000..c500513 --- /dev/null +++ b/internal/resources/telemetrydestination/resource.go @@ -0,0 +1,234 @@ +package telemetrydestination + +import ( + "context" + "errors" + "net/http" + "strconv" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + tfresource "github.com/hashicorp/terraform-plugin-framework/resource" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/terraform-provider-kernel/internal/projectscope" +) + +var ( + _ tfresource.Resource = (*destinationResource)(nil) + _ tfresource.ResourceWithConfigure = (*destinationResource)(nil) + _ tfresource.ResourceWithImportState = (*destinationResource)(nil) +) + +type destinationClient interface { + CreateTelemetryDestination(context.Context, kernel.TelemetryDestinationNewParams) (*kernel.OtlpDestination, error) + GetTelemetryDestination(context.Context, string) (*kernel.OtlpDestination, error) + UpdateTelemetryDestination(context.Context, string, kernel.TelemetryDestinationUpdateParams) (*kernel.OtlpDestination, error) + DeleteTelemetryDestination(context.Context, string) error +} + +type destinationResource struct { + client destinationClient +} + +func NewResource() tfresource.Resource { + return &destinationResource{} +} + +func (r *destinationResource) Metadata(ctx context.Context, req tfresource.MetadataRequest, resp *tfresource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_telemetry_destination" +} + +func (r *destinationResource) Schema(ctx context.Context, req tfresource.SchemaRequest, resp *tfresource.SchemaResponse) { + resp.Schema = destinationSchema() +} + +func (r *destinationResource) Configure(ctx context.Context, req tfresource.ConfigureRequest, resp *tfresource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(destinationClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Kernel Client Type", + "Expected provider data to implement the telemetry destination durable client contract.", + ) + return + } + + r.client = client +} + +func (r *destinationResource) Create(ctx context.Context, req tfresource.CreateRequest, resp *tfresource.CreateResponse) { + var plan destinationModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + if !r.configured(&resp.Diagnostics) { + return + } + + params, diags := expandCreate(ctx, plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + created, err := r.client.CreateTelemetryDestination(ctx, params) + if err != nil { + if definiteFailure(err) { + resp.Diagnostics.AddError("Create Kernel Telemetry Destination", errorDetail(err)) + return + } + addUncertainCreateDiagnostic(&resp.Diagnostics, plan.Name.ValueString(), err.Error()) + return + } + if created == nil { + addUncertainCreateDiagnostic(&resp.Diagnostics, plan.Name.ValueString(), "Kernel returned an empty telemetry destination response.") + return + } + + state, diags := flatten(ctx, *created, plan.Headers) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +func (r *destinationResource) Read(ctx context.Context, req tfresource.ReadRequest, resp *tfresource.ReadResponse) { + var state destinationModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if !r.configured(&resp.Diagnostics) { + return + } + + id := state.ID.ValueString() + remote, err := r.client.GetTelemetryDestination(ctx, id) + if err != nil { + if projectscope.IsNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Read Kernel Telemetry Destination", errorDetail(err)) + return + } + if remote == nil { + resp.Diagnostics.AddError( + "Read Kernel Telemetry Destination", + "Kernel returned an empty response while reading telemetry destination "+strconv.Quote(id)+".", + ) + return + } + + next, diags := flatten(ctx, *remote, state.Headers) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + if next.ID.ValueString() != id { + resp.Diagnostics.AddError( + "Invalid Kernel Telemetry Destination Response", + "Kernel returned telemetry destination ID "+strconv.Quote(next.ID.ValueString())+" while reading Terraform state ID "+strconv.Quote(id)+". "+ + "If this destination was imported by name, remove it from Terraform state and import it again using its ID.", + ) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, next)...) +} + +func (r *destinationResource) Update(ctx context.Context, req tfresource.UpdateRequest, resp *tfresource.UpdateResponse) { + var plan, state destinationModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if !r.configured(&resp.Diagnostics) { + return + } + + params, diags := expandUpdate(ctx, plan, state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + id := state.ID.ValueString() + remote, err := r.client.UpdateTelemetryDestination(ctx, id, params) + if err != nil { + resp.Diagnostics.AddError("Update Kernel Telemetry Destination", errorDetail(err)) + return + } + if remote == nil { + resp.Diagnostics.AddError( + "Update Kernel Telemetry Destination", + "Kernel returned an empty response while updating telemetry destination "+strconv.Quote(id)+".", + ) + return + } + + next, diags := flatten(ctx, *remote, plan.Headers) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, next)...) +} + +func (r *destinationResource) Delete(ctx context.Context, req tfresource.DeleteRequest, resp *tfresource.DeleteResponse) { + var state destinationModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if !r.configured(&resp.Diagnostics) { + return + } + + err := r.client.DeleteTelemetryDestination(ctx, state.ID.ValueString()) + if err != nil && !projectscope.IsNotFound(err) { + resp.Diagnostics.AddError("Delete Kernel Telemetry Destination", errorDetail(err)) + } +} + +func (r *destinationResource) ImportState(ctx context.Context, req tfresource.ImportStateRequest, resp *tfresource.ImportStateResponse) { + tfresource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (r *destinationResource) configured(diags *diag.Diagnostics) bool { + if r.client == nil { + diags.AddError( + "Missing Kernel Client", + "The Kernel provider was not configured before using the telemetry destination resource.", + ) + return false + } + return true +} + +func definiteFailure(err error) bool { + var apiError *kernel.Error + return errors.As(err, &apiError) && apiError.StatusCode >= http.StatusBadRequest && apiError.StatusCode < http.StatusInternalServerError +} + +func errorDetail(err error) string { + var apiError *kernel.Error + if errors.As(err, &apiError) && apiError.StatusCode == http.StatusForbidden { + return "Telemetry destinations are organization-scoped, so the provider's API key must not be bound to a project. Underlying error: " + err.Error() + } + return err.Error() +} + +func addUncertainCreateDiagnostic(diags *diag.Diagnostics, name, reason string) { + diags.AddError( + "Kernel Telemetry Destination Creation Outcome Uncertain", + "Kernel may have created telemetry destination "+strconv.Quote(name)+", but Terraform did not receive a confirmation. "+ + "Check Kernel for the destination. If it exists, import its ID before applying again; otherwise retry the apply. "+ + "Reason: "+reason, + ) +} diff --git a/internal/resources/telemetrydestination/resource_acc_test.go b/internal/resources/telemetrydestination/resource_acc_test.go new file mode 100644 index 0000000..98a153d --- /dev/null +++ b/internal/resources/telemetrydestination/resource_acc_test.go @@ -0,0 +1,168 @@ +package telemetrydestination_test + +import ( + "context" + "fmt" + "maps" + "slices" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/kernel/terraform-provider-kernel/internal/acctest" +) + +const destinationResourceName = "kernel_telemetry_destination.test" + +func TestAccTelemetryDestinationLifecycle(t *testing.T) { + name := acctest.UniqueName(t, "otlp") + updatedName := acctest.UniqueName(t, "otlp-updated") + finalConfig := testAccDestinationConfig(updatedName, "https://otlp.datadoghq.com", "", "") + var destinationID string + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories(), + CheckDestroy: testAccCheckDestinationDestroyed(), + Steps: []resource.TestStep{ + { + Config: testAccDestinationConfig(name, "https://api.honeycomb.io", "acceptance", `{ + "x-honeycomb-team" = "first-key" + }`), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCaptureDestinationID(t, &destinationID), + resource.TestCheckResourceAttr(destinationResourceName, "name", name), + resource.TestCheckResourceAttr(destinationResourceName, "endpoint", "https://api.honeycomb.io"), + resource.TestCheckResourceAttr(destinationResourceName, "description", "acceptance"), + resource.TestCheckResourceAttr(destinationResourceName, "headers.x-honeycomb-team", "first-key"), + testAccCheckRemoteHeaderNames("X-Honeycomb-Team"), + ), + }, + { + Config: testAccDestinationConfig(updatedName, "https://otlp.datadoghq.com", "", `{ + "x-honeycomb-team" = "second-key" + "X-Dataset" = "browsers" + }`), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCaptureDestinationID(t, &destinationID), + resource.TestCheckResourceAttr(destinationResourceName, "name", updatedName), + resource.TestCheckResourceAttr(destinationResourceName, "endpoint", "https://otlp.datadoghq.com"), + resource.TestCheckNoResourceAttr(destinationResourceName, "description"), + resource.TestCheckResourceAttr(destinationResourceName, "headers.%", "2"), + resource.TestCheckResourceAttr(destinationResourceName, "headers.x-honeycomb-team", "second-key"), + testAccCheckRemoteHeaderNames("X-Dataset", "X-Honeycomb-Team"), + ), + }, + { + Config: finalConfig, + Check: resource.ComposeAggregateTestCheckFunc( + testAccCaptureDestinationID(t, &destinationID), + resource.TestCheckNoResourceAttr(destinationResourceName, "headers.%"), + testAccCheckRemoteHeaderNames(), + ), + }, + { + Config: finalConfig, + PlanOnly: true, + }, + { + ResourceName: destinationResourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccDestinationConfig(name, endpoint, description, headers string) string { + config := acctest.ProviderConfig() + fmt.Sprintf(` +resource "kernel_telemetry_destination" "test" { + name = %q + endpoint = %q +`, name, endpoint) + if description != "" { + config += fmt.Sprintf(" description = %q\n", description) + } + if headers != "" { + config += " headers = " + headers + "\n" + } + return config + "}\n" +} + +func testAccCaptureDestinationID(t *testing.T, destinationID *string) resource.TestCheckFunc { + t.Helper() + + return func(state *terraform.State) error { + id, err := destinationStateID(state) + if err != nil { + return err + } + if *destinationID != "" && *destinationID != id { + return fmt.Errorf("Kernel telemetry destination ID changed from %s to %s", *destinationID, id) + } + if *destinationID == "" { + *destinationID = id + acctest.CleanupTelemetryDestination(t, id) + } + return nil + } +} + +func testAccCheckRemoteHeaderNames(want ...string) resource.TestCheckFunc { + return func(state *terraform.State) error { + id, err := destinationStateID(state) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + remote, err := acctest.ClientFromEnv().GetTelemetryDestination(ctx, id) + if err != nil { + return fmt.Errorf("read Kernel telemetry destination %s: %w", id, err) + } + got := slices.Sorted(maps.Keys(remote.Headers)) + if !slices.Equal(got, want) { + return fmt.Errorf("Kernel telemetry destination headers = %v, want %v", got, want) + } + return nil + } +} + +func testAccCheckDestinationDestroyed() resource.TestCheckFunc { + return func(state *terraform.State) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + client := acctest.ClientFromEnv() + for _, resourceState := range state.RootModule().Resources { + if resourceState.Type != "kernel_telemetry_destination" || resourceState.Primary == nil || resourceState.Primary.ID == "" { + continue + } + + _, err := client.GetTelemetryDestination(ctx, resourceState.Primary.ID) + if acctest.IsNotFound(err) { + continue + } + if err != nil { + return fmt.Errorf("read Kernel telemetry destination %s after destroy: %w", resourceState.Primary.ID, err) + } + return fmt.Errorf("Kernel telemetry destination %s still exists after destroy", resourceState.Primary.ID) + } + return nil + } +} + +func destinationStateID(state *terraform.State) (string, error) { + resourceState, ok := state.RootModule().Resources[destinationResourceName] + if !ok { + return "", fmt.Errorf("missing resource %s in Terraform state", destinationResourceName) + } + if resourceState.Primary == nil || resourceState.Primary.ID == "" { + return "", fmt.Errorf("missing ID for %s in Terraform state", destinationResourceName) + } + return resourceState.Primary.ID, nil +} diff --git a/internal/resources/telemetrydestination/schema.go b/internal/resources/telemetrydestination/schema.go new file mode 100644 index 0000000..cd98431 --- /dev/null +++ b/internal/resources/telemetrydestination/schema.go @@ -0,0 +1,58 @@ +package telemetrydestination + +import ( + "regexp" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + rschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +const maxDescriptionLength = 1024 + +var namePattern = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,255}$`) + +func destinationSchema() rschema.Schema { + return rschema.Schema{ + MarkdownDescription: "Kernel OTLP telemetry destination. Browser sessions reference a destination by ID or name in `telemetry.export.otlp.destination` to export their telemetry to it. Destinations are organization-scoped and require an organization-scoped API key.", + Attributes: map[string]rschema.Attribute{ + "id": rschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Unique telemetry destination identifier.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": rschema.StringAttribute{ + Required: true, + MarkdownDescription: "Destination name. Must be unique within the organization, match `^[a-zA-Z0-9._-]{1,255}$`, and not look like an ID.", + Validators: []validator.String{ + stringvalidator.RegexMatches(namePattern, "must contain only letters, digits, '.', '_' or '-' and be 1-255 characters long"), + }, + }, + "endpoint": rschema.StringAttribute{ + Required: true, + MarkdownDescription: "Base OTLP/HTTP collector endpoint, without a signal path such as `/v1/logs`. Must be a public http or https URL with no query string or fragment, for example `https://api.honeycomb.io`.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "description": rschema.StringAttribute{ + Optional: true, + MarkdownDescription: "Destination description.", + Validators: []validator.String{ + stringvalidator.LengthBetween(1, maxDescriptionLength), + }, + }, + "headers": rschema.MapAttribute{ + Optional: true, + Sensitive: true, + ElementType: types.StringType, + MarkdownDescription: "Headers sent with each export request, typically an ingestion key. Header names are matched case-insensitively. Kernel never returns header values, so Terraform detects added or removed header names but not values changed outside Terraform.", + }, + }, + } +} From 8c438567f000950024a4c5a14f9be369c25865ba Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:27:31 +0000 Subject: [PATCH 2/4] Make telemetry destination example vendor-neutral --- docs/resources/telemetry_destination.md | 2 +- examples/telemetry-destination/main.tf | 19 ++++++++++++------- .../resources/telemetrydestination/schema.go | 2 +- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/resources/telemetry_destination.md b/docs/resources/telemetry_destination.md index bedf6f1..28c84d9 100644 --- a/docs/resources/telemetry_destination.md +++ b/docs/resources/telemetry_destination.md @@ -17,7 +17,7 @@ Kernel OTLP telemetry destination. Browser sessions reference a destination by I ### Required -- `endpoint` (String) Base OTLP/HTTP collector endpoint, without a signal path such as `/v1/logs`. Must be a public http or https URL with no query string or fragment, for example `https://api.honeycomb.io`. +- `endpoint` (String) Base OTLP/HTTP collector endpoint, without a signal path such as `/v1/logs`. Must be a public http or https URL with no query string or fragment. - `name` (String) Destination name. Must be unique within the organization, match `^[a-zA-Z0-9._-]{1,255}$`, and not look like an ID. ### Optional diff --git a/examples/telemetry-destination/main.tf b/examples/telemetry-destination/main.tf index 8b61325..45ea3fb 100644 --- a/examples/telemetry-destination/main.tf +++ b/examples/telemetry-destination/main.tf @@ -8,21 +8,26 @@ terraform { provider "kernel" {} -variable "honeycomb_api_key" { +variable "otlp_endpoint" { + type = string + description = "Base OTLP/HTTP collector endpoint, without a signal path such as /v1/logs." +} + +variable "otlp_token" { type = string sensitive = true - description = "Honeycomb ingest key sent with each telemetry export." + description = "Collector credential sent with each telemetry export." } -resource "kernel_telemetry_destination" "honeycomb" { - name = "honeycomb" - endpoint = "https://api.honeycomb.io" +resource "kernel_telemetry_destination" "example" { + name = "otlp-collector" + endpoint = var.otlp_endpoint description = "Browser session telemetry" headers = { - "x-honeycomb-team" = var.honeycomb_api_key + "Authorization" = "Bearer ${var.otlp_token}" } } output "telemetry_destination_id" { - value = kernel_telemetry_destination.honeycomb.id + value = kernel_telemetry_destination.example.id } diff --git a/internal/resources/telemetrydestination/schema.go b/internal/resources/telemetrydestination/schema.go index cd98431..924bdc9 100644 --- a/internal/resources/telemetrydestination/schema.go +++ b/internal/resources/telemetrydestination/schema.go @@ -35,7 +35,7 @@ func destinationSchema() rschema.Schema { }, "endpoint": rschema.StringAttribute{ Required: true, - MarkdownDescription: "Base OTLP/HTTP collector endpoint, without a signal path such as `/v1/logs`. Must be a public http or https URL with no query string or fragment, for example `https://api.honeycomb.io`.", + MarkdownDescription: "Base OTLP/HTTP collector endpoint, without a signal path such as `/v1/logs`. Must be a public http or https URL with no query string or fragment.", Validators: []validator.String{ stringvalidator.LengthAtLeast(1), }, From 329728ff66bd9e068e8b7fef1a40cd2aaab30b57 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:37:26 +0000 Subject: [PATCH 3/4] Address telemetry destination review feedback --- README.md | 4 + docs/resources/telemetry_destination.md | 2 +- .../resources/telemetrydestination/convert.go | 22 +- .../telemetrydestination/convert_test.go | 15 + .../telemetrydestination/resource.go | 11 + .../telemetrydestination/resource_acc_test.go | 7 + .../telemetrydestination/resource_test.go | 456 ++++++++++++++++++ .../resources/telemetrydestination/schema.go | 10 +- .../telemetrydestination/validators.go | 46 ++ .../telemetrydestination/validators_test.go | 59 +++ 10 files changed, 621 insertions(+), 11 deletions(-) create mode 100644 internal/resources/telemetrydestination/resource_test.go create mode 100644 internal/resources/telemetrydestination/validators.go create mode 100644 internal/resources/telemetrydestination/validators_test.go diff --git a/README.md b/README.md index 344625d..5ac3229 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,10 @@ terraform import kernel_project.example terraform import kernel_telemetry_destination.example ``` +Kernel never returns telemetry destination header values, so an imported +destination has empty header values in state and the next apply sends the +configured values. + See the [first public release guide](docs/first-release.md) for the exact public surface and for adopting existing Kernel projects or browser pools. diff --git a/docs/resources/telemetry_destination.md b/docs/resources/telemetry_destination.md index 28c84d9..bbd6ae7 100644 --- a/docs/resources/telemetry_destination.md +++ b/docs/resources/telemetry_destination.md @@ -23,7 +23,7 @@ Kernel OTLP telemetry destination. Browser sessions reference a destination by I ### Optional - `description` (String) Destination description. -- `headers` (Map of String, Sensitive) Headers sent with each export request, typically an ingestion key. Header names are matched case-insensitively. Kernel never returns header values, so Terraform detects added or removed header names but not values changed outside Terraform. +- `headers` (Map of String, Sensitive) Headers sent with each export request, typically an ingestion key. Header names are matched case-insensitively. Kernel never returns header values, so Terraform detects added or removed header names but not values changed outside Terraform. After import, header values are empty in state and the next apply sends the configured values. ### Read-Only diff --git a/internal/resources/telemetrydestination/convert.go b/internal/resources/telemetrydestination/convert.go index a240a52..4c9c6fc 100644 --- a/internal/resources/telemetrydestination/convert.go +++ b/internal/resources/telemetrydestination/convert.go @@ -70,13 +70,14 @@ func expandUpdate(ctx context.Context, plan, state destinationModel) (kernel.Tel // lacks is recorded with an empty value so Terraform plans to correct it. func flatten(ctx context.Context, remote kernel.OtlpDestination, prior types.Map) (destinationModel, diag.Diagnostics) { var diags diag.Diagnostics - for field, value := range map[string]string{"id": remote.ID, "name": remote.Name, "endpoint": remote.Endpoint} { - if value == "" { - diags.AddError( - "Invalid Kernel Telemetry Destination Response", - "Kernel returned a telemetry destination with missing field "+field+".", - ) - } + if remote.ID == "" { + addInvalidResponseDiagnostic(&diags, "id") + } + if remote.Name == "" { + addInvalidResponseDiagnostic(&diags, "name") + } + if remote.Endpoint == "" { + addInvalidResponseDiagnostic(&diags, "endpoint") } priorHeaders, priorDiags := headerMap(ctx, prior) diags.Append(priorDiags...) @@ -123,3 +124,10 @@ func headerMap(ctx context.Context, value types.Map) (map[string]string, diag.Di diags := value.ElementsAs(ctx, &headers, false) return headers, diags } + +func addInvalidResponseDiagnostic(diags *diag.Diagnostics, field string) { + diags.AddError( + "Invalid Kernel Telemetry Destination Response", + "Kernel returned a telemetry destination with missing field "+field+".", + ) +} diff --git a/internal/resources/telemetrydestination/convert_test.go b/internal/resources/telemetrydestination/convert_test.go index 97f8c1a..5c01526 100644 --- a/internal/resources/telemetrydestination/convert_test.go +++ b/internal/resources/telemetrydestination/convert_test.go @@ -206,3 +206,18 @@ func TestSchemaMarksHeadersSensitive(t *testing.T) { t.Fatal("headers should be sensitive") } } + +func TestFlattenImportRecordsHeaderNamesWithEmptyValues(t *testing.T) { + t.Parallel() + + remote := destinationForTest(t, `{"id":"dest_123","name":"honeycomb","endpoint":"https://api.honeycomb.io","headers":{"Authorization":"","X-Dataset":""}}`) + + got, diags := flatten(context.Background(), remote, types.MapNull(types.StringType)) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + want := headersValue(t, map[string]string{"Authorization": "", "X-Dataset": ""}) + if !got.Headers.Equal(want) { + t.Fatalf("headers = %v, want %v", got.Headers, want) + } +} diff --git a/internal/resources/telemetrydestination/resource.go b/internal/resources/telemetrydestination/resource.go index c500513..3b4e7d4 100644 --- a/internal/resources/telemetrydestination/resource.go +++ b/internal/resources/telemetrydestination/resource.go @@ -30,6 +30,10 @@ type destinationResource struct { client destinationClient } +func newResourceWithClient(client destinationClient) *destinationResource { + return &destinationResource{client: client} +} + func NewResource() tfresource.Resource { return &destinationResource{} } @@ -177,6 +181,13 @@ func (r *destinationResource) Update(ctx context.Context, req tfresource.UpdateR if resp.Diagnostics.HasError() { return } + if next.ID.ValueString() != id { + resp.Diagnostics.AddError( + "Invalid Kernel Telemetry Destination Response", + "Kernel returned telemetry destination ID "+strconv.Quote(next.ID.ValueString())+" while updating "+strconv.Quote(id)+".", + ) + return + } resp.Diagnostics.Append(resp.State.Set(ctx, next)...) } diff --git a/internal/resources/telemetrydestination/resource_acc_test.go b/internal/resources/telemetrydestination/resource_acc_test.go index 98a153d..d9b10e0 100644 --- a/internal/resources/telemetrydestination/resource_acc_test.go +++ b/internal/resources/telemetrydestination/resource_acc_test.go @@ -56,6 +56,13 @@ func TestAccTelemetryDestinationLifecycle(t *testing.T) { testAccCheckRemoteHeaderNames("X-Dataset", "X-Honeycomb-Team"), ), }, + { + // Kernel redacts header values, so an import records names only. + ResourceName: destinationResourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"headers"}, + }, { Config: finalConfig, Check: resource.ComposeAggregateTestCheckFunc( diff --git a/internal/resources/telemetrydestination/resource_test.go b/internal/resources/telemetrydestination/resource_test.go new file mode 100644 index 0000000..462dc9c --- /dev/null +++ b/internal/resources/telemetrydestination/resource_test.go @@ -0,0 +1,456 @@ +package telemetrydestination + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/url" + "strings" + "testing" + + tfresource "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" +) + +type fakeDestinationClient struct { + create func(context.Context, kernel.TelemetryDestinationNewParams) (*kernel.OtlpDestination, error) + get func(context.Context, string) (*kernel.OtlpDestination, error) + update func(context.Context, string, kernel.TelemetryDestinationUpdateParams) (*kernel.OtlpDestination, error) + delete func(context.Context, string) error +} + +func (f fakeDestinationClient) CreateTelemetryDestination(ctx context.Context, params kernel.TelemetryDestinationNewParams) (*kernel.OtlpDestination, error) { + if f.create == nil { + return nil, errors.New("unexpected create") + } + return f.create(ctx, params) +} + +func (f fakeDestinationClient) GetTelemetryDestination(ctx context.Context, id string) (*kernel.OtlpDestination, error) { + if f.get == nil { + return nil, errors.New("unexpected get") + } + return f.get(ctx, id) +} + +func (f fakeDestinationClient) UpdateTelemetryDestination(ctx context.Context, id string, params kernel.TelemetryDestinationUpdateParams) (*kernel.OtlpDestination, error) { + if f.update == nil { + return nil, errors.New("unexpected update") + } + return f.update(ctx, id, params) +} + +func (f fakeDestinationClient) DeleteTelemetryDestination(ctx context.Context, id string) error { + if f.delete == nil { + return errors.New("unexpected delete") + } + return f.delete(ctx, id) +} + +const remoteDestination = `{"id":"dest_123","name":"honeycomb","endpoint":"https://api.honeycomb.io","headers":{"Authorization":""}}` + +func destinationResponse(t *testing.T, raw string) (*kernel.OtlpDestination, error) { + t.Helper() + destination := destinationForTest(t, raw) + return &destination, nil +} + +func apiError(t *testing.T, status int, raw string) *kernel.Error { + t.Helper() + + var apiError kernel.Error + if err := json.Unmarshal([]byte(raw), &apiError); err != nil { + t.Fatalf("unmarshal API error: %v", err) + } + apiError.StatusCode = status + apiError.Request = &http.Request{ + Method: http.MethodPost, + URL: &url.URL{Scheme: "https", Host: "api.example", Path: "/telemetry/destinations"}, + } + apiError.Response = &http.Response{StatusCode: status, Status: http.StatusText(status)} + return &apiError +} + +func newState(t *testing.T, value *destinationModel) tfsdk.State { + t.Helper() + + state := tfsdk.State{Schema: destinationSchema()} + if value == nil { + state.RemoveResource(context.Background()) + return state + } + if diags := state.Set(context.Background(), value); diags.HasError() { + t.Fatalf("set state: %v", diags) + } + return state +} + +func newPlan(t *testing.T, value destinationModel) tfsdk.Plan { + t.Helper() + + plan := tfsdk.Plan{Schema: destinationSchema()} + if diags := plan.Set(context.Background(), value); diags.HasError() { + t.Fatalf("set plan: %v", diags) + } + return plan +} + +func stateModel(t *testing.T, state tfsdk.State) destinationModel { + t.Helper() + + var m destinationModel + if diags := state.Get(context.Background(), &m); diags.HasError() { + t.Fatalf("get state: %v", diags) + } + return m +} + +func plannedModel(t *testing.T) destinationModel { + t.Helper() + + m := model(t, nil, map[string]string{"authorization": "key"}) + m.ID = types.StringUnknown() + return m +} + +func TestCreatePersistsStateWithConfiguredHeaderValues(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeDestinationClient{ + create: func(context.Context, kernel.TelemetryDestinationNewParams) (*kernel.OtlpDestination, error) { + return destinationResponse(t, remoteDestination) + }, + }) + resp := &tfresource.CreateResponse{State: newState(t, nil)} + + r.Create(context.Background(), tfresource.CreateRequest{Plan: newPlan(t, plannedModel(t))}, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + got := stateModel(t, resp.State) + if got.ID.ValueString() != "dest_123" { + t.Fatalf("state id = %q, want dest_123", got.ID.ValueString()) + } + if want := headersValue(t, map[string]string{"authorization": "key"}); !got.Headers.Equal(want) { + t.Fatalf("headers = %v, want %v", got.Headers, want) + } +} + +func TestCreateFailures(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + create func() (*kernel.OtlpDestination, error) + wantSummary string + wantDetail string + }{ + "definite API rejection": { + create: func() (*kernel.OtlpDestination, error) { + return nil, apiError(t, http.StatusConflict, `{"code":"conflict"}`) + }, + wantSummary: "Create Kernel Telemetry Destination", + }, + "project-scoped credential": { + create: func() (*kernel.OtlpDestination, error) { + return nil, apiError(t, http.StatusForbidden, `{"code":"forbidden"}`) + }, + wantSummary: "Create Kernel Telemetry Destination", + wantDetail: "must not be bound to a project", + }, + "server error": { + create: func() (*kernel.OtlpDestination, error) { + return nil, apiError(t, http.StatusInternalServerError, `{"code":"internal_error"}`) + }, + wantSummary: "Kernel Telemetry Destination Creation Outcome Uncertain", + }, + "transport error": { + create: func() (*kernel.OtlpDestination, error) { + return nil, errors.New("connection reset") + }, + wantSummary: "Kernel Telemetry Destination Creation Outcome Uncertain", + wantDetail: "connection reset", + }, + "empty response": { + create: func() (*kernel.OtlpDestination, error) { + return nil, nil + }, + wantSummary: "Kernel Telemetry Destination Creation Outcome Uncertain", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeDestinationClient{ + create: func(context.Context, kernel.TelemetryDestinationNewParams) (*kernel.OtlpDestination, error) { + return test.create() + }, + }) + resp := &tfresource.CreateResponse{State: newState(t, nil)} + + r.Create(context.Background(), tfresource.CreateRequest{Plan: newPlan(t, plannedModel(t))}, resp) + + if got := len(resp.Diagnostics); got != 1 { + t.Fatalf("diagnostics = %v, want one error", resp.Diagnostics) + } + if got := resp.Diagnostics[0].Summary(); got != test.wantSummary { + t.Fatalf("summary = %q, want %q", got, test.wantSummary) + } + if got := resp.Diagnostics[0].Detail(); !strings.Contains(got, test.wantDetail) { + t.Fatalf("detail = %q, want it to contain %q", got, test.wantDetail) + } + if !resp.State.Raw.IsNull() { + t.Fatal("failed create persisted state") + } + }) + } +} + +func TestReadRefreshesState(t *testing.T) { + t.Parallel() + + var gotID string + r := newResourceWithClient(fakeDestinationClient{ + get: func(ctx context.Context, id string) (*kernel.OtlpDestination, error) { + gotID = id + return destinationResponse(t, `{"id":"dest_123","name":"renamed","endpoint":"https://api.honeycomb.io","headers":{"Authorization":""}}`) + }, + }) + prior := model(t, nil, map[string]string{"authorization": "key"}) + resp := &tfresource.ReadResponse{State: newState(t, &prior)} + + r.Read(context.Background(), tfresource.ReadRequest{State: newState(t, &prior)}, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if gotID != "dest_123" { + t.Fatalf("get id = %q, want dest_123", gotID) + } + got := stateModel(t, resp.State) + if got.Name.ValueString() != "renamed" { + t.Fatalf("name = %q, want renamed", got.Name.ValueString()) + } + if !got.Headers.Equal(prior.Headers) { + t.Fatalf("headers = %v, want %v", got.Headers, prior.Headers) + } +} + +func TestReadRemovesConfirmedMissingDestination(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeDestinationClient{ + get: func(context.Context, string) (*kernel.OtlpDestination, error) { + return nil, apiError(t, http.StatusNotFound, `{"code":"not_found"}`) + }, + }) + prior := model(t, nil, nil) + resp := &tfresource.ReadResponse{State: newState(t, &prior)} + + r.Read(context.Background(), tfresource.ReadRequest{State: newState(t, &prior)}, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if !resp.State.Raw.IsNull() { + t.Fatal("missing destination was not removed from state") + } +} + +func TestReadKeepsStateOnUncodedNotFound(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeDestinationClient{ + get: func(context.Context, string) (*kernel.OtlpDestination, error) { + return nil, apiError(t, http.StatusNotFound, `{"code":"project_not_found"}`) + }, + }) + prior := model(t, nil, nil) + resp := &tfresource.ReadResponse{State: newState(t, &prior)} + + r.Read(context.Background(), tfresource.ReadRequest{State: newState(t, &prior)}, resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected read error") + } + if resp.State.Raw.IsNull() { + t.Fatal("uncoded 404 removed the destination from state") + } +} + +func TestReadRejectsCanonicalIDMismatch(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeDestinationClient{ + get: func(context.Context, string) (*kernel.OtlpDestination, error) { + return destinationResponse(t, `{"id":"dest_456","name":"honeycomb","endpoint":"https://api.honeycomb.io","headers":{}}`) + }, + }) + prior := model(t, nil, nil) + resp := &tfresource.ReadResponse{State: newState(t, &prior)} + + r.Read(context.Background(), tfresource.ReadRequest{State: newState(t, &prior)}, resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected ID mismatch error") + } + if got := stateModel(t, resp.State).ID.ValueString(); got != "dest_123" { + t.Fatalf("state id = %q, want preserved dest_123", got) + } +} + +func TestUpdateSendsPatchAndPersistsPlannedHeaders(t *testing.T) { + t.Parallel() + + var gotID, gotBody string + r := newResourceWithClient(fakeDestinationClient{ + update: func(ctx context.Context, id string, params kernel.TelemetryDestinationUpdateParams) (*kernel.OtlpDestination, error) { + gotID = id + gotBody = marshal(t, params) + return destinationResponse(t, `{"id":"dest_123","name":"honeycomb","endpoint":"https://api.honeycomb.io","headers":{"X-New":""}}`) + }, + }) + prior := model(t, nil, map[string]string{"Authorization": "key"}) + plan := model(t, nil, map[string]string{"X-New": "value"}) + resp := &tfresource.UpdateResponse{State: newState(t, &prior)} + + r.Update(context.Background(), tfresource.UpdateRequest{Plan: newPlan(t, plan), State: newState(t, &prior)}, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if gotID != "dest_123" { + t.Fatalf("update id = %q, want dest_123", gotID) + } + if want := `{"headers":{"Authorization":null,"X-New":"value"}}`; gotBody != want { + t.Fatalf("update body = %s, want %s", gotBody, want) + } + if got := stateModel(t, resp.State); !got.Headers.Equal(plan.Headers) { + t.Fatalf("headers = %v, want %v", got.Headers, plan.Headers) + } +} + +func TestUpdateRejectsIDMismatch(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeDestinationClient{ + update: func(context.Context, string, kernel.TelemetryDestinationUpdateParams) (*kernel.OtlpDestination, error) { + return destinationResponse(t, `{"id":"dest_456","name":"renamed","endpoint":"https://api.honeycomb.io","headers":{}}`) + }, + }) + prior := model(t, nil, nil) + plan := model(t, nil, nil) + plan.Name = types.StringValue("renamed") + resp := &tfresource.UpdateResponse{State: newState(t, &prior)} + + r.Update(context.Background(), tfresource.UpdateRequest{Plan: newPlan(t, plan), State: newState(t, &prior)}, resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected ID mismatch error") + } +} + +func TestDeleteToleratesMissingDestination(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeDestinationClient{ + delete: func(context.Context, string) error { + return apiError(t, http.StatusNotFound, `{"code":"not_found"}`) + }, + }) + prior := model(t, nil, nil) + resp := &tfresource.DeleteResponse{State: newState(t, &prior)} + + r.Delete(context.Background(), tfresource.DeleteRequest{State: newState(t, &prior)}, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } +} + +func TestDeleteReportsConflict(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeDestinationClient{ + delete: func(context.Context, string) error { + return apiError(t, http.StatusConflict, `{"code":"conflict","message":"in use by 1 browser session(s)"}`) + }, + }) + prior := model(t, nil, nil) + resp := &tfresource.DeleteResponse{State: newState(t, &prior)} + + r.Delete(context.Background(), tfresource.DeleteRequest{State: newState(t, &prior)}, resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected delete conflict error") + } +} + +func TestImportStateSetsID(t *testing.T) { + t.Parallel() + + resp := &tfresource.ImportStateResponse{State: newState(t, nil)} + + NewResource().(*destinationResource).ImportState(context.Background(), tfresource.ImportStateRequest{ID: "dest_123"}, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + got := stateModel(t, resp.State) + if got.ID.ValueString() != "dest_123" { + t.Fatalf("state id = %q, want dest_123", got.ID.ValueString()) + } + if !got.Headers.IsNull() { + t.Fatalf("headers = %v, want null before refresh", got.Headers) + } +} + +func TestConfigure(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + data any + wantError bool + wantClient bool + }{ + "durable client": {data: fakeDestinationClient{}, wantClient: true}, + "unexpected data": {data: "not a client", wantError: true}, + "nil provider data": {}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + r := &destinationResource{} + var resp tfresource.ConfigureResponse + r.Configure(context.Background(), tfresource.ConfigureRequest{ProviderData: test.data}, &resp) + + if got := resp.Diagnostics.HasError(); got != test.wantError { + t.Fatalf("has error = %t, want %t: %v", got, test.wantError, resp.Diagnostics) + } + if got := r.client != nil; got != test.wantClient { + t.Fatalf("client configured = %t, want %t", got, test.wantClient) + } + }) + } +} + +func TestOperationsRequireConfiguredClient(t *testing.T) { + t.Parallel() + + r := &destinationResource{} + prior := model(t, nil, nil) + resp := &tfresource.ReadResponse{State: newState(t, &prior)} + + r.Read(context.Background(), tfresource.ReadRequest{State: newState(t, &prior)}, resp) + + if !resp.Diagnostics.HasError() || resp.Diagnostics[0].Summary() != "Missing Kernel Client" { + t.Fatalf("diagnostics = %v, want Missing Kernel Client", resp.Diagnostics) + } +} diff --git a/internal/resources/telemetrydestination/schema.go b/internal/resources/telemetrydestination/schema.go index 924bdc9..be9f930 100644 --- a/internal/resources/telemetrydestination/schema.go +++ b/internal/resources/telemetrydestination/schema.go @@ -37,21 +37,25 @@ func destinationSchema() rschema.Schema { Required: true, MarkdownDescription: "Base OTLP/HTTP collector endpoint, without a signal path such as `/v1/logs`. Must be a public http or https URL with no query string or fragment.", Validators: []validator.String{ - stringvalidator.LengthAtLeast(1), + stringvalidator.RegexMatches(trimmedPattern, "must be non-empty with no leading or trailing whitespace"), }, }, "description": rschema.StringAttribute{ Optional: true, MarkdownDescription: "Destination description.", Validators: []validator.String{ - stringvalidator.LengthBetween(1, maxDescriptionLength), + stringvalidator.LengthAtMost(maxDescriptionLength), + stringvalidator.RegexMatches(trimmedPattern, "must be non-empty with no leading or trailing whitespace"), }, }, "headers": rschema.MapAttribute{ Optional: true, Sensitive: true, ElementType: types.StringType, - MarkdownDescription: "Headers sent with each export request, typically an ingestion key. Header names are matched case-insensitively. Kernel never returns header values, so Terraform detects added or removed header names but not values changed outside Terraform.", + MarkdownDescription: "Headers sent with each export request, typically an ingestion key. Header names are matched case-insensitively. Kernel never returns header values, so Terraform detects added or removed header names but not values changed outside Terraform. After import, header values are empty in state and the next apply sends the configured values.", + Validators: []validator.Map{ + uniqueHeaderNamesValidator{}, + }, }, }, } diff --git a/internal/resources/telemetrydestination/validators.go b/internal/resources/telemetrydestination/validators.go new file mode 100644 index 0000000..f1cceef --- /dev/null +++ b/internal/resources/telemetrydestination/validators.go @@ -0,0 +1,46 @@ +package telemetrydestination + +import ( + "context" + "net/textproto" + "regexp" + "strconv" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +var _ validator.Map = uniqueHeaderNamesValidator{} + +// trimmedPattern rejects leading or trailing whitespace, which the API trims +// on write and would otherwise surface as a perpetual diff. +var trimmedPattern = regexp.MustCompile(`^\S(?s:.*\S)?$`) + +type uniqueHeaderNamesValidator struct{} + +func (uniqueHeaderNamesValidator) Description(context.Context) string { + return "header names must be unique when compared case-insensitively" +} + +func (v uniqueHeaderNamesValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (uniqueHeaderNamesValidator) ValidateMap(_ context.Context, req validator.MapRequest, resp *validator.MapResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + seen := make(map[string]string, len(req.ConfigValue.Elements())) + for name := range req.ConfigValue.Elements() { + key := textproto.CanonicalMIMEHeaderKey(name) + if other, ok := seen[key]; ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Duplicate Header Name", + "Headers "+strconv.Quote(other)+" and "+strconv.Quote(name)+" are the same header; header names are case-insensitive.", + ) + return + } + seen[key] = name + } +} diff --git a/internal/resources/telemetrydestination/validators_test.go b/internal/resources/telemetrydestination/validators_test.go new file mode 100644 index 0000000..bde02a5 --- /dev/null +++ b/internal/resources/telemetrydestination/validators_test.go @@ -0,0 +1,59 @@ +package telemetrydestination + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestUniqueHeaderNamesValidator(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + headers types.Map + wantError bool + }{ + "distinct names": {headers: headersValue(t, map[string]string{"Authorization": "a", "X-Dataset": "b"})}, + "null": {headers: types.MapNull(types.StringType)}, + "unknown": {headers: types.MapUnknown(types.StringType)}, + "two spellings": {headers: headersValue(t, map[string]string{"authorization": "a", "Authorization": "b"}), wantError: true}, + "canonicalized dupes": {headers: headersValue(t, map[string]string{"x-dataset": "a", "X-DATASET": "b"}), wantError: true}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + var resp validator.MapResponse + uniqueHeaderNamesValidator{}.ValidateMap(context.Background(), validator.MapRequest{ + Path: path.Root("headers"), + ConfigValue: test.headers, + }, &resp) + if got := resp.Diagnostics.HasError(); got != test.wantError { + t.Fatalf("has error = %t, want %t: %v", got, test.wantError, resp.Diagnostics) + } + }) + } +} + +func TestTrimmedPattern(t *testing.T) { + t.Parallel() + + for value, want := range map[string]bool{ + "https://api.honeycomb.io": true, + "multi\nline": true, + "x": true, + "": false, + " ": false, + " leading": false, + "trailing ": false, + "trailing\n": false, + } { + if got := trimmedPattern.MatchString(value); got != want { + t.Fatalf("trimmedPattern.MatchString(%q) = %t, want %t", value, got, want) + } + } +} From a99e4bfa5290165d3070f9b1b637bd257a909e02 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:20:26 +0000 Subject: [PATCH 4/4] Reject ID-shaped telemetry destination names at plan time --- .../resources/telemetrydestination/schema.go | 1 + .../telemetrydestination/validators.go | 39 +++++++++++++++++-- .../telemetrydestination/validators_test.go | 31 +++++++++++++++ 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/internal/resources/telemetrydestination/schema.go b/internal/resources/telemetrydestination/schema.go index be9f930..b838c2a 100644 --- a/internal/resources/telemetrydestination/schema.go +++ b/internal/resources/telemetrydestination/schema.go @@ -31,6 +31,7 @@ func destinationSchema() rschema.Schema { MarkdownDescription: "Destination name. Must be unique within the organization, match `^[a-zA-Z0-9._-]{1,255}$`, and not look like an ID.", Validators: []validator.String{ stringvalidator.RegexMatches(namePattern, "must contain only letters, digits, '.', '_' or '-' and be 1-255 characters long"), + notIDShapedValidator{}, }, }, "endpoint": rschema.StringAttribute{ diff --git a/internal/resources/telemetrydestination/validators.go b/internal/resources/telemetrydestination/validators.go index f1cceef..8fedace 100644 --- a/internal/resources/telemetrydestination/validators.go +++ b/internal/resources/telemetrydestination/validators.go @@ -9,11 +9,42 @@ import ( "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) -var _ validator.Map = uniqueHeaderNamesValidator{} +var ( + _ validator.String = notIDShapedValidator{} + _ validator.Map = uniqueHeaderNamesValidator{} +) + +var ( + // trimmedPattern rejects leading or trailing whitespace, which the API trims + // on write and would otherwise surface as a perpetual diff. + trimmedPattern = regexp.MustCompile(`^\S(?s:.*\S)?$`) + // cuidPattern matches Kernel resource IDs. The API rejects names of this + // shape so a name can't be confused with an ID in id-or-name lookups. + cuidPattern = regexp.MustCompile(`^[a-z0-9]{24}$`) +) + +type notIDShapedValidator struct{} + +func (notIDShapedValidator) Description(context.Context) string { + return "must not be shaped like a Kernel ID (24 lowercase letters or digits)" +} + +func (v notIDShapedValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} -// trimmedPattern rejects leading or trailing whitespace, which the API trims -// on write and would otherwise surface as a perpetual diff. -var trimmedPattern = regexp.MustCompile(`^\S(?s:.*\S)?$`) +func (notIDShapedValidator) ValidateString(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + if cuidPattern.MatchString(req.ConfigValue.ValueString()) { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Telemetry Destination Name", + "name must not be shaped like a Kernel ID (24 lowercase letters or digits), because names and IDs share lookups.", + ) + } +} type uniqueHeaderNamesValidator struct{} diff --git a/internal/resources/telemetrydestination/validators_test.go b/internal/resources/telemetrydestination/validators_test.go index bde02a5..9488cce 100644 --- a/internal/resources/telemetrydestination/validators_test.go +++ b/internal/resources/telemetrydestination/validators_test.go @@ -57,3 +57,34 @@ func TestTrimmedPattern(t *testing.T) { } } } + +func TestNotIDShapedValidator(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + name types.String + wantError bool + }{ + "ordinary name": {name: types.StringValue("otlp-collector")}, + "24 chars with capital": {name: types.StringValue("Abcdefghijklmnopqrstuvwx")}, + "25 lowercase chars": {name: types.StringValue("abcdefghijklmnopqrstuvwxy")}, + "null": {name: types.StringNull()}, + "unknown": {name: types.StringUnknown()}, + "cuid shaped": {name: types.StringValue("rtk6e7snm77j251v8qhqrk43"), wantError: true}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + var resp validator.StringResponse + notIDShapedValidator{}.ValidateString(context.Background(), validator.StringRequest{ + Path: path.Root("name"), + ConfigValue: test.name, + }, &resp) + if got := resp.Diagnostics.HasError(); got != test.wantError { + t.Fatalf("has error = %t, want %t: %v", got, test.wantError, resp.Diagnostics) + } + }) + } +}