From b23992dca7b44f582cedd9ce90112e341ef3d959 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:51:46 -0400 Subject: [PATCH 01/68] Define v1 provider architecture Set the durable v1 target and release gates before resource implementation begins. Co-authored-by: Cursor --- docs/architecture.md | 109 ++++++++++++++++++++++++++++++++----------- docs/release.md | 19 ++++---- 2 files changed, 92 insertions(+), 36 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 71e4b5f..523b377 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,12 +1,12 @@ # Kernel Terraform Provider Architecture -This document records the v0 architecture and phase-2 delivery plan for the Kernel Terraform provider. +This document records the durable-only architecture, the implemented v0 baseline, and the target scope for the first public v1 of the Kernel Terraform provider. ## First Principles Terraform should manage durable desired state. Kernel runtime operations stay in the Kernel SDK and API. -For v0, the provider must be boring and direct: +The provider must remain boring and direct: - Use Terraform Plugin Framework in Go. - Build a Go plugin binary. @@ -38,20 +38,62 @@ Data sources: Import: -- `kernel_browser_pool` imports by canonical browser pool ID. +- `kernel_browser_pool` imports by canonical browser pool ID, optionally qualified as `/`. + +## v1 Target Scope + +The first public v1 should make durable Kernel configuration production-ready without turning Terraform into a runtime control plane. Core items are release-blocking unless the release notes explicitly defer them with an upstream API or SDK blocker. + +Resources require stable identity, refresh, delete, import, and, where applicable, project-scoping and sensitive-state semantics. Data sources require stable identity, deterministic exact lookup, and, where applicable, masked sensitive metadata, pagination, and project scoping. Tooling experiments require deterministic regeneration and must preserve the handwritten lifecycle boundary. + +Core v1 resources: + +- `kernel_project` for basic project lifecycle; project limits remain separate and deferred +- `kernel_browser_pool`, preserving and hardening the v0 durable model +- `kernel_profile` for metadata lifecycle; runtime-written archive contents remain excluded +- `kernel_extension` for uploaded packages with stable content checksums +- `kernel_deployment` for deployment lifecycle, not app invocation + +Core v1 data sources: + +- `kernel_project` +- `kernel_browser_pool` +- `kernel_profile` +- `kernel_proxy` +- `kernel_extension` +- `kernel_deployment` +- `kernel_app` + +Late or conditional v1 work: + +- `kernel_proxy` resource, after write-only credential and import semantics are accepted +- masked `kernel_api_key` metadata lookup +- `kernel_api_key` resource, only after plaintext-once, retry, rotation, import, and provider self-use semantics are accepted +- project limits, only after their lifecycle is clearly separate from basic project management +- an accepted, non-blocking code-generation canary for the `kernel_extension` data-source schema and model + +Blocked candidates must remain unimplemented until the API and a tagged SDK expose the required durable contract. Provider code must not guess missing semantics, patch generated SDK code, or add a fallback HTTP client to bypass the durable client module. + +The code-generation canary may proceed from a curated Terraform specification without waiting for broad OpenAPI contract cleanup. Broad OpenAPI-driven generation remains deferred. ## Explicit Non-Goals -These are intentionally not Terraform resources or actions in v0: +These are intentionally not Terraform resources or actions: - browser sessions -- acquire/release +- acquire, release, or browser-pool flush - app invocation -- logs, screenshots, or live view -- runtime status or standby state -- force-release/recovery operations -- API key resources -- project resources +- `kernel_app` resources; apps remain lookup-only unless a later API exposes a separate durable app lifecycle +- invocation cleanup or status mutation +- logs, screenshots, live view, or telemetry streams +- runtime counters, standby state, lease state, or session state +- force-release, recovery, or managed runtime browser updates +- billing, organization membership, audit-log, managed-auth, or internal administration resources +- standalone secrets without a dedicated durable secrets API + +`force_destroy` for browser pools is deferred beyond the initial v1 scope unless a demonstrated workflow justifies a later architecture amendment. Terraform should not terminate leased runtime work as ordinary durable-resource cleanup. + +Chrome Web Store download is not an extension resource because downloading a package does not create a durable Kernel extension record. ## Package Layout @@ -59,11 +101,8 @@ These are intentionally not Terraform resources or actions in v0: cmd/terraform-provider-kernel/main.go internal/provider/ internal/kernelclient/ -internal/resources/browserpool/ -internal/datasources/project/ -internal/datasources/profile/ -internal/datasources/proxy/ -internal/datasources/extension/ +internal/resources// +internal/datasources// internal/acctest/ docs/ examples/ @@ -74,7 +113,7 @@ Ownership: - `cmd/terraform-provider-kernel` starts the provider plugin. - `internal/provider` owns provider registration, provider schema, and resource/data-source wiring. - `internal/kernelclient` owns SDK construction and durable API operations only. -- `internal/resources/browserpool` owns the browser pool Terraform schema, model, CRUD, import, and tests. +- `internal/resources/*` owns one durable resource package per Kernel type, including schema, model, lifecycle, import, and tests. - `internal/datasources/*` owns one data source package per Kernel durable lookup type. - `internal/acctest` owns opt-in acceptance test helpers. @@ -84,7 +123,7 @@ Ownership: It must not expose SDK runtime methods such as acquire, release, flush, force-release, session operations, logs, screenshots, or live view. This creates a compile-time guard against accidentally wiring runtime Kernel operations into Terraform. -The provider uses the Kernel Go SDK only. There is no fallback HTTP client in v0. +The provider uses tagged releases of the Kernel Go SDK only. There is no fallback HTTP client. ## Provider Configuration @@ -125,7 +164,7 @@ Runtime fields are intentionally excluded, including acquired counts, available Durable fields with server defaults use Terraform `Optional + Computed` semantics so create/read/import can round-trip API-defaulted durable configuration without future preserve-null special cases. Runtime fields are still excluded rather than modeled as computed attributes. -`profile_save_changes` is intentionally omitted in v0 because the browser pool API currently rejects it for browser pools. +`profile_save_changes` is intentionally omitted because saving browser-session changes is not part of the browser-pool durable contract. `chrome_policy` is stored as written at the Terraform boundary (the raw JSON object string); Terraform semantic equality treats key-order- or whitespace-different but equivalent JSON as unchanged, and a malformed value is rejected by the attribute validator rather than during value conversion. It is normalized only for comparison and decoded into the SDK shape only at the final SDK call boundary. @@ -138,17 +177,28 @@ Each data source should be lookup-only and side-effect free. Expected lookup shape: - project: lookup current or named project metadata +- browser pool: lookup durable pool configuration - profile: lookup profile by ID or supported selector - proxy: lookup proxy by ID or supported selector - extension: lookup extension by ID or supported selector +- deployment: lookup durable deployment metadata +- app: lookup a currently runnable app version without invoking it +- API key, if accepted: masked metadata only + +Lookup semantics must be deterministic: + +- zero exact matches fail +- one exact match succeeds +- multiple exact matches fail +- fuzzy matches never silently win Data sources must not create, mutate, acquire, release, invoke, or recover Kernel runtime objects. ## Import Behavior -`kernel_browser_pool` import uses the canonical browser pool ID. +Every resource should import by canonical ID where the API can reconstruct durable state. Project-scoped resources may also accept a documented project-qualified form when needed to resolve a non-default project. -Read after import must flatten durable API state into Terraform state without introducing runtime fields. If the API returns values v0 cannot represent safely, the provider should return a clear diagnostic instead of guessing. +Read after import must flatten durable API state into Terraform state without introducing runtime fields. If the API cannot return create-only configuration or sensitive values, the resource must document metadata-only import or remain deferred. The provider returns a clear diagnostic instead of guessing. ## Testing Strategy @@ -168,6 +218,7 @@ Acceptance tests must: - create uniquely named resources - clean up after themselves - avoid browser/session runtime operations +- exercise import and real delete behavior for each resource ## PR Slicing @@ -177,26 +228,27 @@ Prefer PRs that add one durable behavior at a time, with tests that prove the ne ## Review Gates -Every PR loop has five gates: +Every PR loop has six sequential gates: 1. `deslop` -2. `autoreview` -3. `thermo-nuclear-code-quality-review` +2. incremental self-review +3. `autoreview` 4. `dave-cheney-go-review` 5. `eblog-code-review` +6. final agreement pass Loop: 1. Implement the PR scope. 2. Run gofmt, go test, go vet, and relevant Terraform validation. -3. Run all five review gates. +3. Run all six review gates in order. 4. Fix every accepted and actionable finding. 5. Rerun tests. -6. Rerun all five review gates. -7. Repeat until all five gates are clean. +6. Rerun the affected review gates. +7. Repeat until the final agreement pass is clean. 8. Push and open/update the PR. -If review gates conflict, choose the simpler and safer design unless it violates Terraform semantics. +Use additional specialist security, API-contract, or code-quality reviews when the PR's risk warrants them. If review gates conflict, choose the simpler and safer design unless it violates Terraform semantics. ## Release And Docs Strategy @@ -211,4 +263,5 @@ Release checklist: - Sensitive values are marked sensitive. - Runtime operations are absent from Terraform resources. - Import behavior is documented. -- Release process and versioning are documented before v0 publication. +- API and SDK blockers are either resolved or explicitly deferred. +- Release process, signing, licensing, and versioning are complete before the first public v1 publication. diff --git a/docs/release.md b/docs/release.md index ac42051..3bbbe30 100644 --- a/docs/release.md +++ b/docs/release.md @@ -12,18 +12,18 @@ Use this checklist before publishing a Kernel Terraform provider version. - Run `terraform fmt -check -recursive examples`. - Run `go test -short -timeout=2m ./...`. - Run `go vet ./...`. -- Run opt-in acceptance tests with real credentials before the first public release: - - `TF_ACC=1 KERNEL_ACC=1 KERNEL_API_KEY=... KERNEL_PROJECT_ID=... go test -count=1 -timeout=30m -v ./internal/resources/browserpool -run TestAcc` - - or run the manual `Acceptance` GitHub Actions workflow with `KERNEL_API_KEY` and `KERNEL_PROJECT_ID` repository secrets configured. +- Run the complete opt-in acceptance matrix for every v1 resource and data source with real credentials before the first public release. + - The current v0 baseline command is `TF_ACC=1 KERNEL_ACC=1 KERNEL_API_KEY=... KERNEL_PROJECT_ID=... go test -count=1 -timeout=30m -v ./internal/resources/browserpool -run TestAcc`. + - Expand the manual `Acceptance` workflow as v1 resources land; keep live tests out of normal PR CI. - Verify unscoped API calls send no `X-Kernel-Project-Id` header; it is sent only when a resource-level `project_id` or the provider default resolves a project. - Confirm `terraform-registry-manifest.json` contains protocol `["6.0"]` for Terraform Plugin Framework. - Confirm the repository license before the first public release. Do not publish a public tag until `LICENSE` exists or the release owner has explicitly documented the licensing decision. - Confirm GitHub private vulnerability reporting or a public security contact is configured and reflected in `SECURITY.md`. -- Confirm there is no branch named like the release tag, for example `v0.1.0`. +- Confirm there is no branch named like the release tag, for example `v1.0.0`. ## Registry Release Assets -Terraform Registry provider releases are GitHub Releases with semver tags prefixed by `v`, such as `v0.1.0`. +Terraform Registry provider releases are GitHub Releases with semver tags prefixed by `v`, such as `v1.0.0`. Each release must include: @@ -55,12 +55,15 @@ Do not replace or mutate assets for a published version. If an asset, checksum, ## Security And State Review - Provider `api_key` remains sensitive. -- No resource or data source exposes Kernel API keys, project creation, or project mutation. - `internal/kernelclient` exposes durable methods only; no acquire, release, flush, force-release, screenshots, logs, live view, or app invocation. -- `kernel_browser_pool` state contains durable desired configuration only. +- Every resource state contains durable desired configuration only. +- Every data source is lookup-only and side-effect free. +- Project lifecycle uses organization-scoped endpoints and documents the permissions required for create, archive, and delete; if project limits are included later, their permissions receive a separate review. +- If API key management is included, reads expose masked metadata only; plaintext-once values are sensitive, import cannot recover plaintext, and rotation/self-use semantics have explicit safety review. +- Proxy credentials, deployment environment variables, source tokens, and other secret inputs are sensitive and preserve configured state when API reads return masked values. - Browser pool read state does not include runtime counters, standby state, leased-browser state, runtime URLs, screenshots, logs, or live-view fields. - Delete uses `force=false`; Terraform must not terminate leased browsers as cleanup. -- Import sets the canonical browser pool ID and relies on read-after-import to settle state. +- Each resource imports by canonical ID where the API can reconstruct durable state; metadata-only or unsupported imports are documented rather than guessed. - Acceptance tests create unique resources and register cleanup without force-delete behavior. - Generated docs match schema output from `scripts/check-docs.sh`. - Release artifacts are signed and checksummed before the GitHub release is finalized. From 10249d729144c17eee54a3745e9392ac763510f8 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:52:24 -0400 Subject: [PATCH 02/68] Document v1 codegen rejection --- docs/architecture.md | 5 +-- docs/codegen.md | 81 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 docs/codegen.md diff --git a/docs/architecture.md b/docs/architecture.md index 523b377..492c4bd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -70,11 +70,12 @@ Late or conditional v1 work: - masked `kernel_api_key` metadata lookup - `kernel_api_key` resource, only after plaintext-once, retry, rotation, import, and provider self-use semantics are accepted - project limits, only after their lifecycle is clearly separate from basic project management -- an accepted, non-blocking code-generation canary for the `kernel_extension` data-source schema and model Blocked candidates must remain unimplemented until the API and a tagged SDK expose the required durable contract. Provider code must not guess missing semantics, patch generated SDK code, or add a fallback HTTP client to bypass the durable client module. -The code-generation canary may proceed from a curated Terraform specification without waiting for broad OpenAPI contract cleanup. Broad OpenAPI-driven generation remains deferred. +Terraform schema and model code generation remains deferred. The current tool produced valid output but did not reduce code or review complexity, and broad OpenAPI-driven generation would further weaken the durable allowlist. + +The evaluation evidence and reconsideration criteria are defined in [Terraform Framework Code Generation Decision](codegen.md). ## Explicit Non-Goals diff --git a/docs/codegen.md b/docs/codegen.md new file mode 100644 index 0000000..df80ff2 --- /dev/null +++ b/docs/codegen.md @@ -0,0 +1,81 @@ +# Terraform Framework Code Generation Decision + +## Decision + +Keep Terraform schemas and models handwritten for v1. Do not adopt +`tfplugingen-framework` or add a code-generation drift check. + +HashiCorp currently labels provider code generation a tech preview. Version +v0.4.1 was the latest release when evaluated on 2026-07-10. + +References: + +- [Framework code generator](https://developer.hashicorp.com/terraform/plugin/code-generation/framework-generator) +- [Provider code specification](https://developer.hashicorp.com/terraform/plugin/code-generation/specification) + +## Prototype Result + +A disposable prototype generated the existing `kernel_extension` data-source +schema and Terraform model from a handwritten Provider Code Specification. + +The generator correctly reproduced: + +- all six attributes; +- required, optional, and computed flags; +- Markdown descriptions; +- the `project_id` length validator; +- package name `extension`; +- a Terraform model with matching `tfsdk` tags. + +The generated shape still failed the canary's complexity test: + +- 70 lines of checked-in specification; +- 64 lines of generated Go; +- 17 lines for the pinned regeneration command; +- 151 total lines to replace 57 lines of direct schema and model code. + +The generated model also uses `Id` and `ProjectId` rather than the existing +idiomatic `ID` and `ProjectID`, creating mechanical churn across the extension +package. Generated attributes set both plain and Markdown descriptions, while +the provider currently needs only Markdown descriptions. + +The output is valid, but it does not delete complexity. It adds a second source +representation, generated output, tool boot/download cost, a future CI drift +check, and naming churn for one small flat schema. + +## Ownership After Rejection + +The existing package boundaries remain unchanged: + +- `internal/datasources/extension` owns its direct schema, model, lookup, and + tests; +- `internal/provider` owns registration; +- `internal/kernelclient` owns durable SDK calls only; +- Terraform null, unknown, sensitive, lookup, import, and lifecycle semantics + remain handwritten; +- OpenAPI, Stainless, and SDK types do not generate Terraform behavior. + +No generator binary, Provider Code Specification, generated Go, wrapper, tool +module, or CI step is added to the repository. + +## Reconsideration Criteria + +Re-evaluate code generation only if the provider later has enough repeated +nested schema shape that a prototype proves all of these: + +- generated plus specification code is materially smaller or easier to review + than direct Go; +- generated names preserve idiomatic Go initialisms without wrapper types; +- the tool is stable enough for routine upgrades; +- output is deterministic on a clean checkout; +- ordinary unit tests remain independent of the generator and network; +- any drift check is cached, credential-free, Docker-free, and measurably fast; +- generated types stay inside their resource or data-source package; +- CRUD, import, project scope, sensitive state, retries, clear semantics, and + runtime exclusions remain handwritten. + +Until those conditions are demonstrated, issue #25 is rejected for v1 rather +than left as hidden follow-up work. + +Future reconsideration remains possible only under the criteria above; +code generation is not part of the v1 work list. From 3fff757014b049b3aa0807a974011c07a74c0bbb Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:23:05 -0400 Subject: [PATCH 03/68] Update Kernel Go SDK to v0.76.0 Use the latest tagged SDK while keeping provider behavior unchanged. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e0b117c..97efed7 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.72.0 + github.com/kernel/kernel-go-sdk v0.76.0 ) require ( diff --git a/go.sum b/go.sum index 2f142ce..c1fa851 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.72.0 h1:QyT5v2PMJjp9GQCGV4fi1IWcCF/fcsFcz1MTXPA/WZU= -github.com/kernel/kernel-go-sdk v0.72.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +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/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= From f42974c448b340d7399938157b7c7b4dbc0cdf26 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:56:53 -0400 Subject: [PATCH 04/68] Add durable project client methods Expose org-scoped project mutations without automatic retries so Terraform resource work can use the generated SDK safely. --- internal/kernelclient/client.go | 12 ++++++ internal/kernelclient/client_test.go | 62 +++++++++++++++++++++------- 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/internal/kernelclient/client.go b/internal/kernelclient/client.go index 93adab6..71f39cd 100644 --- a/internal/kernelclient/client.go +++ b/internal/kernelclient/client.go @@ -78,12 +78,24 @@ func (c Clients) DefaultProjectID() string { // Projects are org-scoped, so their methods take no project. +func (c Clients) CreateProject(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { + return c.projects.New(ctx, params, noMutationRetries()) +} + // GetProject resolves a project by ID or by name; the API treats the path // parameter as id-or-name (names are unique within an organization). func (c Clients) GetProject(ctx context.Context, idOrName string) (*kernel.Project, error) { return c.projects.Get(ctx, idOrName) } +func (c Clients) UpdateProject(ctx context.Context, id string, params kernel.ProjectUpdateParams) (*kernel.Project, error) { + return c.projects.Update(ctx, id, params, noMutationRetries()) +} + +func (c Clients) DeleteProject(ctx context.Context, id string) error { + return c.projects.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 1050ab6..23f9fe0 100644 --- a/internal/kernelclient/client_test.go +++ b/internal/kernelclient/client_test.go @@ -297,25 +297,55 @@ func TestClientsDoNotReadSDKEnvironmentDefaults(t *testing.T) { } } -func TestBrowserPoolMutationsDisableSDKRetries(t *testing.T) { +func TestMutationsDisableSDKRetriesAndUseExpectedScope(t *testing.T) { t.Parallel() tests := map[string]struct { - method string - path string - call func(context.Context, Clients) error + method string + path string + projectID string + call func(context.Context, Clients) error }{ - "create": { + "project create": { method: http.MethodPost, - path: "/browser_pools", + path: "/org/projects", call: func(ctx context.Context, clients Clients) error { - _, err := clients.CreateBrowserPool(ctx, "project_123", kernel.BrowserPoolNewParams{Size: 1}) + _, err := clients.CreateProject(ctx, kernel.ProjectNewParams{ + CreateProjectRequest: kernel.CreateProjectRequestParam{Name: "Project"}, + }) return err }, }, - "update": { + "project update": { method: http.MethodPatch, - path: "/browser_pools/pool_123", + path: "/org/projects/project_123", + call: func(ctx context.Context, clients Clients) error { + _, err := clients.UpdateProject(ctx, "project_123", kernel.ProjectUpdateParams{ + UpdateProjectRequest: kernel.UpdateProjectRequestParam{Name: kernel.String("Renamed")}, + }) + return err + }, + }, + "project delete": { + method: http.MethodDelete, + path: "/org/projects/project_123", + call: func(ctx context.Context, clients Clients) error { + return clients.DeleteProject(ctx, "project_123") + }, + }, + "browser pool create": { + method: http.MethodPost, + path: "/browser_pools", + projectID: "project_123", + call: func(ctx context.Context, clients Clients) error { + _, err := clients.CreateBrowserPool(ctx, "project_123", kernel.BrowserPoolNewParams{Size: 1}) + return err + }, + }, + "browser pool update": { + method: http.MethodPatch, + path: "/browser_pools/pool_123", + projectID: "project_123", call: func(ctx context.Context, clients Clients) error { _, err := clients.UpdateBrowserPool(ctx, "project_123", "pool_123", kernel.BrowserPoolUpdateParams{ Size: kernel.Int(2), @@ -323,9 +353,10 @@ func TestBrowserPoolMutationsDisableSDKRetries(t *testing.T) { return err }, }, - "delete": { - method: http.MethodDelete, - path: "/browser_pools/pool_123", + "browser pool delete": { + method: http.MethodDelete, + path: "/browser_pools/pool_123", + projectID: "project_123", call: func(ctx context.Context, clients Clients) error { return clients.DeleteBrowserPool(ctx, "project_123", "pool_123") }, @@ -338,8 +369,9 @@ func TestBrowserPoolMutationsDisableSDKRetries(t *testing.T) { var requests []capturedRequest clients := New(Config{ - APIKey: "test-api-key", - BaseURL: "https://api.example", + APIKey: "test-api-key", + BaseURL: "https://api.example", + ProjectID: "default_project", }, WithHTTPClient(recordingHTTPClientWithStatus(&requests, http.StatusInternalServerError, func(req *http.Request) string { if req.Method != test.method { t.Fatalf("method = %s, want %s", req.Method, test.method) @@ -360,7 +392,7 @@ func TestBrowserPoolMutationsDisableSDKRetries(t *testing.T) { if got, want := requests[0].RetryCount, "0"; got != want { t.Fatalf("retry count header = %q, want %q", got, want) } - if got, want := requests[0].ProjectID, "project_123"; got != want { + if got, want := requests[0].ProjectID, test.projectID; got != want { t.Fatalf("project header = %q, want %q", got, want) } }) From 51e83b563eb8b9252c6908d49d6d8138ec9279a8 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:33:29 -0400 Subject: [PATCH 05/68] Add project resource model helpers Define the durable project schema and pure SDK conversion boundary before wiring resource lifecycle behavior. --- internal/resources/project/convert.go | 65 ++++++++++++ internal/resources/project/convert_test.go | 111 +++++++++++++++++++++ internal/resources/project/model.go | 8 ++ internal/resources/project/schema.go | 28 ++++++ internal/resources/project/schema_test.go | 68 +++++++++++++ 5 files changed, 280 insertions(+) create mode 100644 internal/resources/project/convert.go create mode 100644 internal/resources/project/convert_test.go create mode 100644 internal/resources/project/model.go create mode 100644 internal/resources/project/schema.go create mode 100644 internal/resources/project/schema_test.go diff --git a/internal/resources/project/convert.go b/internal/resources/project/convert.go new file mode 100644 index 0000000..f42cc14 --- /dev/null +++ b/internal/resources/project/convert.go @@ -0,0 +1,65 @@ +package project + +import ( + "encoding/json" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" +) + +func expandProjectCreate(model projectModel) (kernel.ProjectNewParams, diag.Diagnostics) { + var diags diag.Diagnostics + if model.Name.IsNull() || model.Name.IsUnknown() { + diags.AddAttributeError( + path.Root("name"), + "Invalid Project Name", + "name must be known before creating a Kernel project.", + ) + return kernel.ProjectNewParams{}, diags + } + + return kernel.ProjectNewParams{ + CreateProjectRequest: kernel.CreateProjectRequestParam{ + Name: model.Name.ValueString(), + }, + }, diags +} + +func flattenProject(project kernel.Project) (projectModel, diag.Diagnostics) { + var diags diag.Diagnostics + if !validResponseString(project.JSON.ID.Raw(), project.JSON.ID.Valid(), project.ID) { + addInvalidResponseDiagnostic(&diags, "id") + } + if !validResponseString(project.JSON.Name.Raw(), project.JSON.Name.Valid(), project.Name) { + addInvalidResponseDiagnostic(&diags, "name") + } + if diags.HasError() { + return projectModel{}, diags + } + + return projectModel{ + ID: types.StringValue(project.ID), + Name: types.StringValue(project.Name), + }, diags +} + +func validResponseString(raw string, valid bool, value string) bool { + if raw == "" || !valid || value == "" { + return false + } + + var decoded string + if err := json.Unmarshal([]byte(raw), &decoded); err != nil { + return false + } + return decoded == value +} + +func addInvalidResponseDiagnostic(diags *diag.Diagnostics, field string) { + diags.AddError( + "Invalid Kernel Project Response", + "Kernel returned a project with missing or invalid field "+field+".", + ) +} diff --git a/internal/resources/project/convert_test.go b/internal/resources/project/convert_test.go new file mode 100644 index 0000000..1e014bc --- /dev/null +++ b/internal/resources/project/convert_test.go @@ -0,0 +1,111 @@ +package project + +import ( + "encoding/json" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" +) + +func TestExpandProjectCreate(t *testing.T) { + t.Parallel() + + params, diags := expandProjectCreate(projectModel{Name: types.StringValue("Project")}) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if got, want := params.CreateProjectRequest.Name, "Project"; got != want { + t.Fatalf("name = %q, want %q", got, want) + } +} + +func TestExpandProjectCreateRejectsUnknownOrNullName(t *testing.T) { + t.Parallel() + + for name, value := range map[string]types.String{ + "null": types.StringNull(), + "unknown": types.StringUnknown(), + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, diags := expandProjectCreate(projectModel{Name: value}) + assertProjectNameDiagnostic(t, diags) + }) + } +} + +func TestFlattenProjectMapsDurableState(t *testing.T) { + t.Parallel() + + state, diags := flattenProject(projectForTest(t, `{"id":"project_123","name":"Project"}`)) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if got, want := state.ID.ValueString(), "project_123"; got != want { + t.Fatalf("id = %q, want %q", got, want) + } + if got, want := state.Name.ValueString(), "Project"; got != want { + t.Fatalf("name = %q, want %q", got, want) + } +} + +func TestFlattenProjectRejectsInvalidDurableFields(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + body string + field string + }{ + "missing id": {body: `{"name":"Project"}`, field: "id"}, + "numeric id": {body: `{"id":123,"name":"Project"}`, field: "id"}, + "missing name": {body: `{"id":"project_123"}`, field: "name"}, + "numeric name": {body: `{"id":"project_123","name":123}`, field: "name"}, + "empty name": {body: `{"id":"project_123","name":""}`, field: "name"}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, diags := flattenProject(projectForTest(t, test.body)) + if len(diags) != 1 { + t.Fatalf("diagnostics = %v, want one error", diags) + } + if got, want := diags[0].Summary(), "Invalid Kernel Project Response"; got != want { + t.Fatalf("diagnostic summary = %q, want %q", got, want) + } + if got, want := diags[0].Detail(), "Kernel returned a project with missing or invalid field "+test.field+"."; got != want { + t.Fatalf("diagnostic detail = %q, want %q", got, want) + } + }) + } +} + +func assertProjectNameDiagnostic(t *testing.T, diags diag.Diagnostics) { + t.Helper() + if len(diags) != 1 { + t.Fatalf("diagnostics = %v, want one error", diags) + } + diagnostic := diags[0] + if got, want := diagnostic.Summary(), "Invalid Project Name"; got != want { + t.Fatalf("diagnostic summary = %q, want %q", got, want) + } + if got, want := diagnostic.Detail(), "name must be known before creating a Kernel project."; got != want { + t.Fatalf("diagnostic detail = %q, want %q", got, want) + } + withPath, ok := diagnostic.(diag.DiagnosticWithPath) + if !ok || !withPath.Path().Equal(path.Root("name")) { + t.Fatalf("diagnostic path = %v, want name", withPath) + } +} + +func projectForTest(t *testing.T, body string) kernel.Project { + t.Helper() + var project kernel.Project + if err := json.Unmarshal([]byte(body), &project); err != nil { + t.Fatalf("unmarshal project: %v", err) + } + return project +} diff --git a/internal/resources/project/model.go b/internal/resources/project/model.go new file mode 100644 index 0000000..1ce3e86 --- /dev/null +++ b/internal/resources/project/model.go @@ -0,0 +1,8 @@ +package project + +import "github.com/hashicorp/terraform-plugin-framework/types" + +type projectModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` +} diff --git a/internal/resources/project/schema.go b/internal/resources/project/schema.go new file mode 100644 index 0000000..570a413 --- /dev/null +++ b/internal/resources/project/schema.go @@ -0,0 +1,28 @@ +package project + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + rschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +const maxProjectNameLength = 255 + +func projectSchema() rschema.Schema { + return rschema.Schema{ + MarkdownDescription: "Kernel project durable configuration.", + Attributes: map[string]rschema.Attribute{ + "id": rschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Unique project identifier.", + }, + "name": rschema.StringAttribute{ + Required: true, + MarkdownDescription: "Project name. Must be unique within the organization.", + Validators: []validator.String{ + stringvalidator.LengthBetween(1, maxProjectNameLength), + }, + }, + }, + } +} diff --git a/internal/resources/project/schema_test.go b/internal/resources/project/schema_test.go new file mode 100644 index 0000000..d9877a2 --- /dev/null +++ b/internal/resources/project/schema_test.go @@ -0,0 +1,68 @@ +package project + +import ( + "context" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + rschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestSchemaContainsOnlyDurableProjectAttributes(t *testing.T) { + t.Parallel() + + schema := projectSchema() + if len(schema.Attributes) != 2 { + t.Fatalf("attribute count = %d, want 2", len(schema.Attributes)) + } + + id, ok := schema.Attributes["id"].(rschema.StringAttribute) + if !ok || !id.Computed || id.Required || id.Optional { + t.Fatalf("id has unexpected schema: %#v", schema.Attributes["id"]) + } + + name, ok := schema.Attributes["name"].(rschema.StringAttribute) + if !ok || !name.Required || name.Computed || name.Optional { + t.Fatalf("name has unexpected schema: %#v", schema.Attributes["name"]) + } + +} + +func TestSchemaValidatesProjectNameLength(t *testing.T) { + t.Parallel() + + name := projectSchema().Attributes["name"].(rschema.StringAttribute) + for _, test := range []struct { + name string + value string + wantError bool + }{ + {name: "empty", value: "", wantError: true}, + {name: "one character", value: "a"}, + {name: "maximum", value: strings.Repeat("a", maxProjectNameLength)}, + {name: "too long", value: strings.Repeat("a", maxProjectNameLength+1), wantError: true}, + {name: "multibyte over byte limit", value: strings.Repeat("é", maxProjectNameLength/2+1), wantError: true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + diags := validateString(name.Validators, test.value) + if diags.HasError() != test.wantError { + t.Fatalf("validation diagnostics = %v, want error %t", diags, test.wantError) + } + }) + } +} + +func validateString(validators []validator.String, value string) diag.Diagnostics { + var diags diag.Diagnostics + for _, candidate := range validators { + req := validator.StringRequest{ConfigValue: types.StringValue(value)} + var resp validator.StringResponse + candidate.ValidateString(context.Background(), req, &resp) + diags.Append(resp.Diagnostics...) + } + return diags +} From fa796631f3095834213fbef44f56cd85dde9f38d Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:49:36 -0400 Subject: [PATCH 06/68] Add project create outcome handling Model definite, successful, and uncertain project creation without exposing a partial Terraform resource. Preserve recoverable identity on ambiguous outcomes and direct operators to import before retrying. --- internal/resources/project/resource.go | 117 +++++++++++++ internal/resources/project/resource_test.go | 172 ++++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 internal/resources/project/resource.go create mode 100644 internal/resources/project/resource_test.go diff --git a/internal/resources/project/resource.go b/internal/resources/project/resource.go new file mode 100644 index 0000000..feb4df7 --- /dev/null +++ b/internal/resources/project/resource.go @@ -0,0 +1,117 @@ +package project + +import ( + "context" + "errors" + "net/http" + "strconv" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" +) + +type projectCreateStatus uint8 + +const ( + projectCreateFailed projectCreateStatus = iota + projectCreateSucceeded + projectCreateUncertain +) + +type projectCreateResult struct { + State projectModel + Status projectCreateStatus +} + +type projectClient interface { + CreateProject(context.Context, kernel.ProjectNewParams) (*kernel.Project, error) +} + +type projectResource struct { + client projectClient +} + +func newResourceWithClient(client projectClient) *projectResource { + return &projectResource{client: client} +} + +func (r *projectResource) create(ctx context.Context, plan projectModel) (projectCreateResult, diag.Diagnostics) { + var diags diag.Diagnostics + if r.client == nil { + addMissingClientDiagnostic(&diags) + return projectCreateResult{Status: projectCreateFailed}, diags + } + + params, expandDiags := expandProjectCreate(plan) + diags.Append(expandDiags...) + if diags.HasError() { + return projectCreateResult{Status: projectCreateFailed}, diags + } + + created, err := r.client.CreateProject(ctx, params) + if err != nil { + if projectCreateFailureIsDefinite(err) { + diags.AddError("Create Kernel Project", err.Error()) + return projectCreateResult{Status: projectCreateFailed}, diags + } + addUncertainProjectCreateDiagnostic(&diags, plan.Name.ValueString(), err.Error()) + return projectCreateResult{Status: projectCreateUncertain}, diags + } + if created == nil { + addUncertainProjectCreateDiagnostic(&diags, plan.Name.ValueString(), "Kernel returned an empty project response.") + return projectCreateResult{Status: projectCreateUncertain}, diags + } + + state, flattenDiags := flattenProject(*created) + if flattenDiags.HasError() { + addUncertainProjectCreateDiagnostic(&diags, plan.Name.ValueString(), flattenDiags[0].Detail()) + return projectCreateResult{ + State: partialProjectState(*created, plan), + Status: projectCreateUncertain, + }, diags + } + if !state.Name.Equal(plan.Name) { + addUncertainProjectCreateDiagnostic( + &diags, + plan.Name.ValueString(), + "Kernel returned project name "+strconv.Quote(state.Name.ValueString())+" instead of the requested name.", + ) + return projectCreateResult{ + State: partialProjectState(*created, plan), + Status: projectCreateUncertain, + }, diags + } + return projectCreateResult{State: state, Status: projectCreateSucceeded}, diags +} + +func addMissingClientDiagnostic(diags *diag.Diagnostics) { + diags.AddError( + "Missing Kernel Client", + "The Kernel provider was not configured before using the project resource.", + ) +} + +func projectCreateFailureIsDefinite(err error) bool { + var apiError *kernel.Error + return errors.As(err, &apiError) && apiError.StatusCode >= http.StatusBadRequest && apiError.StatusCode < http.StatusInternalServerError +} + +func partialProjectState(project kernel.Project, plan projectModel) projectModel { + if !validResponseString(project.JSON.ID.Raw(), project.JSON.ID.Valid(), project.ID) { + return projectModel{} + } + return projectModel{ + ID: types.StringValue(project.ID), + Name: plan.Name, + } +} + +func addUncertainProjectCreateDiagnostic(diags *diag.Diagnostics, name, reason string) { + diags.AddError( + "Kernel Project Creation Outcome Uncertain", + "Kernel may have created project "+strconv.Quote(name)+", but Terraform did not receive a complete confirmation. "+ + "Check Kernel for the project. If it exists and Terraform is not tracking it, import its project ID before applying again. "+ + "Reason: "+reason, + ) +} diff --git a/internal/resources/project/resource_test.go b/internal/resources/project/resource_test.go new file mode 100644 index 0000000..b657915 --- /dev/null +++ b/internal/resources/project/resource_test.go @@ -0,0 +1,172 @@ +package project + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/terraform-provider-kernel/internal/kernelclient" +) + +var _ projectClient = kernelclient.Clients{} + +type fakeProjectClient struct { + create func(context.Context, kernel.ProjectNewParams) (*kernel.Project, error) +} + +func (f fakeProjectClient) CreateProject(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { + if f.create == nil { + return nil, errors.New("unexpected create") + } + return f.create(ctx, params) +} +func TestCreateProjectCreatesAndFlattensState(t *testing.T) { + t.Parallel() + + var gotParams kernel.ProjectNewParams + r := newResourceWithClient(fakeProjectClient{ + create: func(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { + gotParams = params + project := projectForTest(t, `{"id":"project_123","name":"Project"}`) + return &project, nil + }, + }) + + result, diags := r.create(context.Background(), projectModel{Name: types.StringValue("Project")}) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if result.Status != projectCreateSucceeded { + t.Fatalf("create status = %v, want succeeded", result.Status) + } + if got, want := gotParams.CreateProjectRequest.Name, "Project"; got != want { + t.Fatalf("create name = %q, want %q", got, want) + } + if got, want := result.State.ID.ValueString(), "project_123"; got != want { + t.Fatalf("state id = %q, want %q", got, want) + } + if got, want := result.State.Name.ValueString(), "Project"; got != want { + t.Fatalf("state name = %q, want %q", got, want) + } +} + +func TestCreateProjectReturnsDiagnostics(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + resource *projectResource + wantStatus projectCreateStatus + wantSummary string + wantDetail string + wantID string + wantName string + }{ + "missing client": { + resource: &projectResource{}, + wantStatus: projectCreateFailed, + wantSummary: "Missing Kernel Client", + wantDetail: "The Kernel provider was not configured before using the project resource.", + }, + "client error response": { + resource: newResourceWithClient(fakeProjectClient{ + create: func(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { + return nil, projectAPIError(http.StatusConflict) + }, + }), + wantStatus: projectCreateFailed, + wantSummary: "Create Kernel Project", + wantDetail: "409 Conflict", + }, + "transport error after possible commit": { + resource: newResourceWithClient(fakeProjectClient{ + create: func(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { + return nil, errors.New("response timeout") + }, + }), + wantStatus: projectCreateUncertain, + wantSummary: "Kernel Project Creation Outcome Uncertain", + wantDetail: "response timeout", + }, + "empty success response": { + resource: newResourceWithClient(fakeProjectClient{ + create: func(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { + return nil, nil + }, + }), + wantStatus: projectCreateUncertain, + wantSummary: "Kernel Project Creation Outcome Uncertain", + wantDetail: "Kernel returned an empty project response.", + }, + "malformed success preserves valid id": { + resource: newResourceWithClient(fakeProjectClient{ + create: func(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { + project := projectForTest(t, `{"id":"project_123"}`) + return &project, nil + }, + }), + wantStatus: projectCreateUncertain, + wantSummary: "Kernel Project Creation Outcome Uncertain", + wantDetail: "missing or invalid field name", + wantID: "project_123", + wantName: "Project", + }, + "mismatched success name preserves planned name": { + resource: newResourceWithClient(fakeProjectClient{ + create: func(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { + project := projectForTest(t, `{"id":"project_123","name":"Different"}`) + return &project, nil + }, + }), + wantStatus: projectCreateUncertain, + wantSummary: "Kernel Project Creation Outcome Uncertain", + wantDetail: `Kernel returned project name "Different" instead of the requested name.`, + wantID: "project_123", + wantName: "Project", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + result, diags := test.resource.create(context.Background(), projectModel{Name: types.StringValue("Project")}) + if len(diags) != 1 { + t.Fatalf("diagnostics = %v, want one error", diags) + } + if result.Status != test.wantStatus { + t.Fatalf("create status = %v, want %v", result.Status, test.wantStatus) + } + if got := diags[0].Summary(); got != test.wantSummary { + t.Fatalf("diagnostic summary = %q, want %q", got, test.wantSummary) + } + detail := diags[0].Detail() + if !strings.Contains(detail, test.wantDetail) { + t.Fatalf("diagnostic detail = %q, want it to contain %q", detail, test.wantDetail) + } + if test.wantStatus == projectCreateUncertain && !strings.Contains(detail, "import its project ID before applying again") { + t.Fatalf("uncertain diagnostic detail = %q, want import guidance", detail) + } + if got := result.State.ID.ValueString(); got != test.wantID { + t.Fatalf("state id = %q, want %q", got, test.wantID) + } + if got := result.State.Name.ValueString(); got != test.wantName { + t.Fatalf("state name = %q, want %q", got, test.wantName) + } + }) + } +} + +func projectAPIError(status int) *kernel.Error { + return &kernel.Error{ + StatusCode: status, + Request: &http.Request{ + Method: http.MethodPost, + URL: &url.URL{Scheme: "https", Host: "api.example", Path: "/org/projects"}, + }, + Response: &http.Response{StatusCode: status, Status: http.StatusText(status)}, + } +} From 60b3d6e31696c07bed9285efb19081ce14fe3044 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:29:20 -0400 Subject: [PATCH 07/68] Report every malformed project response field Join all project flatten diagnostics in uncertain create outcomes so operators see every invalid durable field. Add a regression test covering simultaneous missing ID and name values. --- internal/resources/project/resource.go | 7 +++++- internal/resources/project/resource_test.go | 27 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/internal/resources/project/resource.go b/internal/resources/project/resource.go index feb4df7..f0bba03 100644 --- a/internal/resources/project/resource.go +++ b/internal/resources/project/resource.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "strconv" + "strings" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" @@ -65,7 +66,11 @@ func (r *projectResource) create(ctx context.Context, plan projectModel) (projec state, flattenDiags := flattenProject(*created) if flattenDiags.HasError() { - addUncertainProjectCreateDiagnostic(&diags, plan.Name.ValueString(), flattenDiags[0].Detail()) + reasons := make([]string, 0, len(flattenDiags)) + for _, flattenDiag := range flattenDiags { + reasons = append(reasons, flattenDiag.Detail()) + } + addUncertainProjectCreateDiagnostic(&diags, plan.Name.ValueString(), strings.Join(reasons, " ")) return projectCreateResult{ State: partialProjectState(*created, plan), Status: projectCreateUncertain, diff --git a/internal/resources/project/resource_test.go b/internal/resources/project/resource_test.go index b657915..08e6324 100644 --- a/internal/resources/project/resource_test.go +++ b/internal/resources/project/resource_test.go @@ -160,6 +160,33 @@ func TestCreateProjectReturnsDiagnostics(t *testing.T) { } } +func TestCreateProjectReportsEveryMalformedResponseField(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeProjectClient{ + create: func(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { + project := projectForTest(t, `{}`) + return &project, nil + }, + }) + + result, diags := r.create(context.Background(), projectModel{Name: types.StringValue("Project")}) + if result.Status != projectCreateUncertain { + t.Fatalf("create status = %v, want uncertain", result.Status) + } + if len(diags) != 1 { + t.Fatalf("diagnostics = %v, want one uncertain-outcome error", diags) + } + for _, reason := range []string{ + "missing or invalid field id", + "missing or invalid field name", + } { + if detail := diags[0].Detail(); !strings.Contains(detail, reason) { + t.Fatalf("diagnostic detail = %q, want it to contain %q", detail, reason) + } + } +} + func projectAPIError(status int) *kernel.Error { return &kernel.Error{ StatusCode: status, From 7129b4ba63df98016b06732c563867007b09eb14 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:11:15 -0400 Subject: [PATCH 08/68] Add project read state handling Read projects by canonical state ID, remove state only for coded not-found responses, and reject malformed or identity-changing API responses. Cover the pure unregistered lifecycle core with focused tests. --- internal/resources/project/read_test.go | 184 ++++++++++++++++++++ internal/resources/project/resource.go | 59 +++++++ internal/resources/project/resource_test.go | 33 +++- 3 files changed, 267 insertions(+), 9 deletions(-) create mode 100644 internal/resources/project/read_test.go diff --git a/internal/resources/project/read_test.go b/internal/resources/project/read_test.go new file mode 100644 index 0000000..30c7f0e --- /dev/null +++ b/internal/resources/project/read_test.go @@ -0,0 +1,184 @@ +package project + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" +) + +func TestReadProjectGetsCanonicalIDAndFlattensState(t *testing.T) { + t.Parallel() + + var gotID string + r := newResourceWithClient(fakeProjectClient{ + get: func(ctx context.Context, id string) (*kernel.Project, error) { + gotID = id + project := projectForTest(t, `{"id":"project_123","name":"Renamed"}`) + return &project, nil + }, + }) + + state, removed, diags := r.read(context.Background(), projectModel{ + ID: types.StringValue("project_123"), + Name: types.StringValue("Original"), + }) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if removed { + t.Fatal("removed = true, want false") + } + if gotID != "project_123" { + t.Fatalf("GetProject id = %q, want canonical state id", gotID) + } + if got := state.ID.ValueString(); got != "project_123" { + t.Fatalf("state id = %q, want project_123", got) + } + if got := state.Name.ValueString(); got != "Renamed" { + t.Fatalf("state name = %q, want remote durable name", got) + } +} + +func TestReadProjectRemovesStateOnlyForCodedNotFound(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + err error + wantRemoved bool + wantError bool + }{ + "coded not found": { + err: projectAPIError(t, http.StatusNotFound, `{"code":"not_found","message":"project not found"}`), + wantRemoved: true, + }, + "uncoded not found": { + err: projectAPIError(t, http.StatusNotFound, `{}`), + wantError: true, + }, + "transport error": { + err: errors.New("connection reset"), + wantError: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + r := newResourceWithClient(fakeProjectClient{ + get: func(ctx context.Context, id string) (*kernel.Project, error) { + return nil, test.err + }, + }) + + _, removed, diags := r.read(context.Background(), projectModel{ID: types.StringValue("project_123")}) + if removed != test.wantRemoved { + t.Fatalf("removed = %t, want %t", removed, test.wantRemoved) + } + if diags.HasError() != test.wantError { + t.Fatalf("has error = %t, want %t; diagnostics: %v", diags.HasError(), test.wantError, diags) + } + }) + } +} + +func TestReadProjectRejectsMissingStateID(t *testing.T) { + t.Parallel() + + tests := map[string]types.String{ + "null": types.StringNull(), + "unknown": types.StringUnknown(), + "empty": types.StringValue(""), + } + + for name, id := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + called := false + r := newResourceWithClient(fakeProjectClient{ + get: func(ctx context.Context, id string) (*kernel.Project, error) { + called = true + return nil, errors.New("unexpected get") + }, + }) + + _, removed, diags := r.read(context.Background(), projectModel{ID: id}) + if removed { + t.Fatal("removed = true, want false") + } + if !diags.HasError() { + t.Fatal("expected missing-id diagnostic") + } + if called { + t.Fatal("GetProject was called without a usable state id") + } + }) + } +} + +func TestReadProjectRejectsInvalidResponses(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + get func(*testing.T, context.Context, string) (*kernel.Project, error) + wantDetail string + }{ + "empty response": { + get: func(t *testing.T, ctx context.Context, id string) (*kernel.Project, error) { + return nil, nil + }, + wantDetail: "empty response", + }, + "missing durable name": { + get: func(t *testing.T, ctx context.Context, id string) (*kernel.Project, error) { + project := projectForTest(t, `{"id":"project_123"}`) + return &project, nil + }, + wantDetail: "missing or invalid field name", + }, + "mismatched id": { + get: func(t *testing.T, ctx context.Context, id string) (*kernel.Project, error) { + project := projectForTest(t, `{"id":"project_other","name":"Project"}`) + return &project, nil + }, + wantDetail: `returned project id "project_other" while reading "project_123"`, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + r := newResourceWithClient(fakeProjectClient{ + get: func(ctx context.Context, id string) (*kernel.Project, error) { + return test.get(t, ctx, id) + }, + }) + _, removed, diags := r.read(context.Background(), projectModel{ID: types.StringValue("project_123")}) + if removed { + t.Fatal("removed = true, want false") + } + if !diags.HasError() { + t.Fatal("expected invalid-response diagnostic") + } + if !strings.Contains(diags[0].Detail(), test.wantDetail) { + t.Fatalf("diagnostic detail = %q, want it to contain %q", diags[0].Detail(), test.wantDetail) + } + }) + } +} + +func TestReadProjectRequiresConfiguredClient(t *testing.T) { + t.Parallel() + + _, removed, diags := (&projectResource{}).read(context.Background(), projectModel{ID: types.StringValue("project_123")}) + if removed { + t.Fatal("removed = true, want false") + } + if !diags.HasError() || diags[0].Summary() != "Missing Kernel Client" { + t.Fatalf("diagnostics = %v, want missing-client error", diags) + } +} diff --git a/internal/resources/project/resource.go b/internal/resources/project/resource.go index f0bba03..6efbb71 100644 --- a/internal/resources/project/resource.go +++ b/internal/resources/project/resource.go @@ -8,8 +8,10 @@ import ( "strings" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/types" kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/terraform-provider-kernel/internal/projectscope" ) type projectCreateStatus uint8 @@ -27,6 +29,7 @@ type projectCreateResult struct { type projectClient interface { CreateProject(context.Context, kernel.ProjectNewParams) (*kernel.Project, error) + GetProject(context.Context, string) (*kernel.Project, error) } type projectResource struct { @@ -90,6 +93,49 @@ func (r *projectResource) create(ctx context.Context, plan projectModel) (projec return projectCreateResult{State: state, Status: projectCreateSucceeded}, diags } +func (r *projectResource) read(ctx context.Context, state projectModel) (projectModel, bool, diag.Diagnostics) { + var diags diag.Diagnostics + if r.client == nil { + addMissingClientDiagnostic(&diags) + return projectModel{}, false, diags + } + + id, ok := stateProjectID(state, "read", &diags) + if !ok { + return projectModel{}, false, diags + } + + remote, err := r.client.GetProject(ctx, id) + if err != nil { + if projectscope.IsNotFound(err) { + return projectModel{}, true, diags + } + diags.AddError("Read Kernel Project", err.Error()) + return projectModel{}, false, diags + } + if remote == nil { + diags.AddError( + "Read Kernel Project", + "Kernel returned an empty response while reading project "+strconv.Quote(id)+".", + ) + return projectModel{}, false, diags + } + + nextState, flattenDiags := flattenProject(*remote) + diags.Append(flattenDiags...) + if diags.HasError() { + return projectModel{}, false, diags + } + if nextState.ID.ValueString() != id { + diags.AddError( + "Invalid Kernel Project Response", + "Kernel returned project id "+strconv.Quote(nextState.ID.ValueString())+" while reading "+strconv.Quote(id)+".", + ) + return projectModel{}, false, diags + } + return nextState, false, diags +} + func addMissingClientDiagnostic(diags *diag.Diagnostics) { diags.AddError( "Missing Kernel Client", @@ -97,6 +143,19 @@ func addMissingClientDiagnostic(diags *diag.Diagnostics) { ) } +func stateProjectID(state projectModel, operation string, diags *diag.Diagnostics) (string, bool) { + if state.ID.IsNull() || state.ID.IsUnknown() || state.ID.ValueString() == "" { + diags.AddAttributeError( + path.Root("id"), + "Missing Kernel Project ID", + "Cannot "+operation+" a Kernel project without a known id in Terraform state.", + ) + return "", false + } + + return state.ID.ValueString(), true +} + func projectCreateFailureIsDefinite(err error) bool { var apiError *kernel.Error return errors.As(err, &apiError) && apiError.StatusCode >= http.StatusBadRequest && apiError.StatusCode < http.StatusInternalServerError diff --git a/internal/resources/project/resource_test.go b/internal/resources/project/resource_test.go index 08e6324..6c43a80 100644 --- a/internal/resources/project/resource_test.go +++ b/internal/resources/project/resource_test.go @@ -2,6 +2,7 @@ package project import ( "context" + "encoding/json" "errors" "net/http" "net/url" @@ -17,6 +18,7 @@ var _ projectClient = kernelclient.Clients{} type fakeProjectClient struct { create func(context.Context, kernel.ProjectNewParams) (*kernel.Project, error) + get func(context.Context, string) (*kernel.Project, error) } func (f fakeProjectClient) CreateProject(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { @@ -25,6 +27,14 @@ func (f fakeProjectClient) CreateProject(ctx context.Context, params kernel.Proj } return f.create(ctx, params) } + +func (f fakeProjectClient) GetProject(ctx context.Context, id string) (*kernel.Project, error) { + if f.get == nil { + return nil, errors.New("unexpected get") + } + return f.get(ctx, id) +} + func TestCreateProjectCreatesAndFlattensState(t *testing.T) { t.Parallel() @@ -75,7 +85,7 @@ func TestCreateProjectReturnsDiagnostics(t *testing.T) { "client error response": { resource: newResourceWithClient(fakeProjectClient{ create: func(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { - return nil, projectAPIError(http.StatusConflict) + return nil, projectAPIError(t, http.StatusConflict, `{}`) }, }), wantStatus: projectCreateFailed, @@ -187,13 +197,18 @@ func TestCreateProjectReportsEveryMalformedResponseField(t *testing.T) { } } -func projectAPIError(status int) *kernel.Error { - return &kernel.Error{ - StatusCode: status, - Request: &http.Request{ - Method: http.MethodPost, - URL: &url.URL{Scheme: "https", Host: "api.example", Path: "/org/projects"}, - }, - Response: &http.Response{StatusCode: status, Status: http.StatusText(status)}, +func projectAPIError(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: "/org/projects"}, } + apiError.Response = &http.Response{StatusCode: status, Status: http.StatusText(status)} + return &apiError } From b8065d0d722123d1cd780ff2e27ef1e2ea8612f2 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:31:25 -0400 Subject: [PATCH 09/68] Fix parallel project test fixtures Build SDK error and response fixtures before parallel Create subtests so helper failures use the owning parent test before child execution begins. --- internal/resources/project/resource_test.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/resources/project/resource_test.go b/internal/resources/project/resource_test.go index 6c43a80..fc2e621 100644 --- a/internal/resources/project/resource_test.go +++ b/internal/resources/project/resource_test.go @@ -68,6 +68,9 @@ func TestCreateProjectCreatesAndFlattensState(t *testing.T) { func TestCreateProjectReturnsDiagnostics(t *testing.T) { t.Parallel() + conflictError := projectAPIError(t, http.StatusConflict, `{}`) + malformedSuccess := projectForTest(t, `{"id":"project_123"}`) + mismatchedSuccess := projectForTest(t, `{"id":"project_123","name":"Different"}`) tests := map[string]struct { resource *projectResource wantStatus projectCreateStatus @@ -85,7 +88,7 @@ func TestCreateProjectReturnsDiagnostics(t *testing.T) { "client error response": { resource: newResourceWithClient(fakeProjectClient{ create: func(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { - return nil, projectAPIError(t, http.StatusConflict, `{}`) + return nil, conflictError }, }), wantStatus: projectCreateFailed, @@ -115,8 +118,7 @@ func TestCreateProjectReturnsDiagnostics(t *testing.T) { "malformed success preserves valid id": { resource: newResourceWithClient(fakeProjectClient{ create: func(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { - project := projectForTest(t, `{"id":"project_123"}`) - return &project, nil + return &malformedSuccess, nil }, }), wantStatus: projectCreateUncertain, @@ -128,8 +130,7 @@ func TestCreateProjectReturnsDiagnostics(t *testing.T) { "mismatched success name preserves planned name": { resource: newResourceWithClient(fakeProjectClient{ create: func(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { - project := projectForTest(t, `{"id":"project_123","name":"Different"}`) - return &project, nil + return &mismatchedSuccess, nil }, }), wantStatus: projectCreateUncertain, From 92bed103bde525663305521799434ca33f747e2f Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:46:16 -0400 Subject: [PATCH 10/68] Add project update params Build a name-only SDK patch for changed project plans, skip unchanged names, and reject unknown values before the network boundary. --- internal/resources/project/convert.go | 21 ++++++ internal/resources/project/convert_test.go | 76 ++++++++++++++++++++-- 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/internal/resources/project/convert.go b/internal/resources/project/convert.go index f42cc14..8ce7054 100644 --- a/internal/resources/project/convert.go +++ b/internal/resources/project/convert.go @@ -27,6 +27,27 @@ func expandProjectCreate(model projectModel) (kernel.ProjectNewParams, diag.Diag }, diags } +func expandProjectUpdate(plan, state projectModel) (kernel.ProjectUpdateParams, bool, diag.Diagnostics) { + var diags diag.Diagnostics + if plan.Name.IsNull() || plan.Name.IsUnknown() { + diags.AddAttributeError( + path.Root("name"), + "Invalid Project Name", + "name must be known before updating a Kernel project.", + ) + return kernel.ProjectUpdateParams{}, false, diags + } + if plan.Name.Equal(state.Name) { + return kernel.ProjectUpdateParams{}, false, diags + } + + return kernel.ProjectUpdateParams{ + UpdateProjectRequest: kernel.UpdateProjectRequestParam{ + Name: kernel.String(plan.Name.ValueString()), + }, + }, true, diags +} + func flattenProject(project kernel.Project) (projectModel, diag.Diagnostics) { var diags diag.Diagnostics if !validResponseString(project.JSON.ID.Raw(), project.JSON.ID.Valid(), project.ID) { diff --git a/internal/resources/project/convert_test.go b/internal/resources/project/convert_test.go index 1e014bc..e4869c0 100644 --- a/internal/resources/project/convert_test.go +++ b/internal/resources/project/convert_test.go @@ -32,7 +32,75 @@ func TestExpandProjectCreateRejectsUnknownOrNullName(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() _, diags := expandProjectCreate(projectModel{Name: value}) - assertProjectNameDiagnostic(t, diags) + assertProjectNameDiagnostic(t, diags, "name must be known before creating a Kernel project.") + }) + } +} + +func TestExpandProjectUpdateBuildsNameOnlyPatch(t *testing.T) { + t.Parallel() + + params, changed, diags := expandProjectUpdate( + projectModel{Name: types.StringValue("Renamed")}, + projectModel{Name: types.StringValue("Original")}, + ) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if !changed { + t.Fatal("changed = false, want true") + } + if got, want := params.UpdateProjectRequest.Name.Value, "Renamed"; got != want { + t.Fatalf("name = %q, want %q", got, want) + } + if !params.UpdateProjectRequest.Name.Valid() { + t.Fatal("name was omitted from update params") + } + + body, err := json.Marshal(params) + if err != nil { + t.Fatalf("marshal update params: %v", err) + } + if got, want := string(body), `{"name":"Renamed"}`; got != want { + t.Fatalf("update body = %s, want %s", got, want) + } +} + +func TestExpandProjectUpdateOmitsUnchangedName(t *testing.T) { + t.Parallel() + + params, changed, diags := expandProjectUpdate( + projectModel{Name: types.StringValue("Project")}, + projectModel{Name: types.StringValue("Project")}, + ) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if changed { + t.Fatal("changed = true, want false") + } + if params.UpdateProjectRequest.Name.Valid() { + t.Fatal("unchanged name was included in update params") + } +} + +func TestExpandProjectUpdateRejectsUnknownOrNullName(t *testing.T) { + t.Parallel() + + for name, value := range map[string]types.String{ + "null": types.StringNull(), + "unknown": types.StringUnknown(), + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, changed, diags := expandProjectUpdate( + projectModel{Name: value}, + projectModel{Name: types.StringValue("Project")}, + ) + if changed { + t.Fatal("changed = true, want false") + } + assertProjectNameDiagnostic(t, diags, "name must be known before updating a Kernel project.") }) } } @@ -83,7 +151,7 @@ func TestFlattenProjectRejectsInvalidDurableFields(t *testing.T) { } } -func assertProjectNameDiagnostic(t *testing.T, diags diag.Diagnostics) { +func assertProjectNameDiagnostic(t *testing.T, diags diag.Diagnostics, wantDetail string) { t.Helper() if len(diags) != 1 { t.Fatalf("diagnostics = %v, want one error", diags) @@ -92,8 +160,8 @@ func assertProjectNameDiagnostic(t *testing.T, diags diag.Diagnostics) { if got, want := diagnostic.Summary(), "Invalid Project Name"; got != want { t.Fatalf("diagnostic summary = %q, want %q", got, want) } - if got, want := diagnostic.Detail(), "name must be known before creating a Kernel project."; got != want { - t.Fatalf("diagnostic detail = %q, want %q", got, want) + if got := diagnostic.Detail(); got != wantDetail { + t.Fatalf("diagnostic detail = %q, want %q", got, wantDetail) } withPath, ok := diagnostic.(diag.DiagnosticWithPath) if !ok || !withPath.Path().Equal(path.Root("name")) { From 01d32846d6c4bb25c5d871ffb345e81285ced221 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:01:07 -0400 Subject: [PATCH 11/68] Add project update core Apply name-only patches by canonical state ID, skip no-op updates, and validate complete SDK responses before returning new Terraform state. --- internal/resources/project/resource.go | 58 +++++++ internal/resources/project/resource_test.go | 8 + internal/resources/project/update_test.go | 174 ++++++++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 internal/resources/project/update_test.go diff --git a/internal/resources/project/resource.go b/internal/resources/project/resource.go index 6efbb71..06a99c8 100644 --- a/internal/resources/project/resource.go +++ b/internal/resources/project/resource.go @@ -30,6 +30,7 @@ type projectCreateResult struct { type projectClient interface { CreateProject(context.Context, kernel.ProjectNewParams) (*kernel.Project, error) GetProject(context.Context, string) (*kernel.Project, error) + UpdateProject(context.Context, string, kernel.ProjectUpdateParams) (*kernel.Project, error) } type projectResource struct { @@ -136,6 +137,63 @@ func (r *projectResource) read(ctx context.Context, state projectModel) (project return nextState, false, diags } +func (r *projectResource) update(ctx context.Context, plan, state projectModel) (projectModel, diag.Diagnostics) { + var diags diag.Diagnostics + if r.client == nil { + addMissingClientDiagnostic(&diags) + return projectModel{}, diags + } + + id, ok := stateProjectID(state, "update", &diags) + if !ok { + return projectModel{}, diags + } + + params, changed, expandDiags := expandProjectUpdate(plan, state) + diags.Append(expandDiags...) + if diags.HasError() { + return projectModel{}, diags + } + if !changed { + return state, diags + } + + remote, err := r.client.UpdateProject(ctx, id, params) + if err != nil { + diags.AddError("Update Kernel Project", err.Error()) + return projectModel{}, diags + } + if remote == nil { + diags.AddError( + "Update Kernel Project", + "Kernel returned an empty response while updating project "+strconv.Quote(id)+".", + ) + return projectModel{}, diags + } + + nextState, flattenDiags := flattenProject(*remote) + diags.Append(flattenDiags...) + if diags.HasError() { + return projectModel{}, diags + } + if nextState.ID.ValueString() != id { + diags.AddError( + "Invalid Kernel Project Response", + "Kernel returned project id "+strconv.Quote(nextState.ID.ValueString())+" while updating "+strconv.Quote(id)+".", + ) + return projectModel{}, diags + } + if !nextState.Name.Equal(plan.Name) { + diags.AddError( + "Invalid Kernel Project Response", + "Kernel returned project name "+strconv.Quote(nextState.Name.ValueString())+" after updating to "+strconv.Quote(plan.Name.ValueString())+".", + ) + return projectModel{}, diags + } + + return nextState, diags +} + func addMissingClientDiagnostic(diags *diag.Diagnostics) { diags.AddError( "Missing Kernel Client", diff --git a/internal/resources/project/resource_test.go b/internal/resources/project/resource_test.go index fc2e621..f16e789 100644 --- a/internal/resources/project/resource_test.go +++ b/internal/resources/project/resource_test.go @@ -19,6 +19,7 @@ var _ projectClient = kernelclient.Clients{} type fakeProjectClient struct { create func(context.Context, kernel.ProjectNewParams) (*kernel.Project, error) get func(context.Context, string) (*kernel.Project, error) + update func(context.Context, string, kernel.ProjectUpdateParams) (*kernel.Project, error) } func (f fakeProjectClient) CreateProject(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { @@ -35,6 +36,13 @@ func (f fakeProjectClient) GetProject(ctx context.Context, id string) (*kernel.P return f.get(ctx, id) } +func (f fakeProjectClient) UpdateProject(ctx context.Context, id string, params kernel.ProjectUpdateParams) (*kernel.Project, error) { + if f.update == nil { + return nil, errors.New("unexpected update") + } + return f.update(ctx, id, params) +} + func TestCreateProjectCreatesAndFlattensState(t *testing.T) { t.Parallel() diff --git a/internal/resources/project/update_test.go b/internal/resources/project/update_test.go new file mode 100644 index 0000000..1c23ac8 --- /dev/null +++ b/internal/resources/project/update_test.go @@ -0,0 +1,174 @@ +package project + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" +) + +func TestUpdateProjectUsesCanonicalIDAndFlattensResponse(t *testing.T) { + t.Parallel() + + var gotID string + var gotParams kernel.ProjectUpdateParams + r := newResourceWithClient(fakeProjectClient{ + update: func(ctx context.Context, id string, params kernel.ProjectUpdateParams) (*kernel.Project, error) { + gotID = id + gotParams = params + project := projectForTest(t, `{"id":"project_123","name":"Renamed"}`) + return &project, nil + }, + }) + + nextState, diags := r.update( + context.Background(), + projectModel{ID: types.StringValue("ignored-plan-id"), Name: types.StringValue("Renamed")}, + projectModel{ID: types.StringValue("project_123"), Name: types.StringValue("Original")}, + ) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if gotID != "project_123" { + t.Fatalf("UpdateProject id = %q, want canonical state id", gotID) + } + if got, want := gotParams.UpdateProjectRequest.Name.Value, "Renamed"; got != want { + t.Fatalf("update name = %q, want %q", got, want) + } + if got := nextState.ID.ValueString(); got != "project_123" { + t.Fatalf("state id = %q, want project_123", got) + } + if got := nextState.Name.ValueString(); got != "Renamed" { + t.Fatalf("state name = %q, want Renamed", got) + } +} + +func TestUpdateProjectSkipsUnchangedName(t *testing.T) { + t.Parallel() + + called := false + r := newResourceWithClient(fakeProjectClient{ + update: func(ctx context.Context, id string, params kernel.ProjectUpdateParams) (*kernel.Project, error) { + called = true + return nil, errors.New("unexpected update") + }, + }) + state := projectModel{ID: types.StringValue("project_123"), Name: types.StringValue("Project")} + + nextState, diags := r.update(context.Background(), state, state) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if called { + t.Fatal("UpdateProject was called for an unchanged name") + } + if !nextState.ID.Equal(state.ID) || !nextState.Name.Equal(state.Name) { + t.Fatalf("state = %#v, want unchanged %#v", nextState, state) + } +} + +func TestUpdateProjectReturnsDiagnostics(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + state projectModel + response string + err func(*testing.T) error + wantSummary string + wantDetail string + }{ + "missing state id": { + state: projectModel{ID: types.StringNull(), Name: types.StringValue("Original")}, + wantSummary: "Missing Kernel Project ID", + wantDetail: "without a known id", + }, + "client error": { + state: projectModel{ID: types.StringValue("project_123"), Name: types.StringValue("Original")}, + err: func(t *testing.T) error { + return projectAPIError(t, http.StatusConflict, `{"code":"conflict"}`) + }, + wantSummary: "Update Kernel Project", + wantDetail: "409 Conflict", + }, + "empty response": { + state: projectModel{ID: types.StringValue("project_123"), Name: types.StringValue("Original")}, + wantSummary: "Update Kernel Project", + wantDetail: "empty response", + }, + "malformed response": { + state: projectModel{ID: types.StringValue("project_123"), Name: types.StringValue("Original")}, + response: `{"id":"project_123"}`, + wantSummary: "Invalid Kernel Project Response", + wantDetail: "missing or invalid field name", + }, + "mismatched response id": { + state: projectModel{ID: types.StringValue("project_123"), Name: types.StringValue("Original")}, + response: `{"id":"project_other","name":"Renamed"}`, + wantSummary: "Invalid Kernel Project Response", + wantDetail: `returned project id "project_other" while updating "project_123"`, + }, + "mismatched response name": { + state: projectModel{ID: types.StringValue("project_123"), Name: types.StringValue("Original")}, + response: `{"id":"project_123","name":"Different"}`, + wantSummary: "Invalid Kernel Project Response", + wantDetail: `returned project name "Different" after updating to "Renamed"`, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + var response *kernel.Project + if test.response != "" { + project := projectForTest(t, test.response) + response = &project + } + var updateErr error + if test.err != nil { + updateErr = test.err(t) + } + called := false + r := newResourceWithClient(fakeProjectClient{ + update: func(ctx context.Context, id string, params kernel.ProjectUpdateParams) (*kernel.Project, error) { + called = true + return response, updateErr + }, + }) + + _, diags := r.update( + context.Background(), + projectModel{Name: types.StringValue("Renamed")}, + test.state, + ) + if len(diags) != 1 { + t.Fatalf("diagnostics = %v, want one error", diags) + } + if got := diags[0].Summary(); got != test.wantSummary { + t.Fatalf("diagnostic summary = %q, want %q", got, test.wantSummary) + } + if detail := diags[0].Detail(); !strings.Contains(detail, test.wantDetail) { + t.Fatalf("diagnostic detail = %q, want it to contain %q", detail, test.wantDetail) + } + if test.state.ID.IsNull() && called { + t.Fatal("UpdateProject was called without a state id") + } + }) + } +} + +func TestUpdateProjectRequiresConfiguredClient(t *testing.T) { + t.Parallel() + + _, diags := (&projectResource{}).update( + context.Background(), + projectModel{Name: types.StringValue("Renamed")}, + projectModel{ID: types.StringValue("project_123"), Name: types.StringValue("Original")}, + ) + if !diags.HasError() || diags[0].Summary() != "Missing Kernel Client" { + t.Fatalf("diagnostics = %v, want missing-client error", diags) + } +} From f454542ab5e464817142950d5c2bdd92b9bda218 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:30:56 -0400 Subject: [PATCH 12/68] Add project delete core Implement non-force project deletion with explicit state-preserving diagnostics, idempotent coded-not-found handling, and focused status-boundary tests. Document the API's indistinguishable projects-disabled not-found response as a residual provider risk. --- internal/resources/project/delete_test.go | 155 ++++++++++++++++++++ internal/resources/project/resource.go | 37 +++++ internal/resources/project/resource_test.go | 8 + 3 files changed, 200 insertions(+) create mode 100644 internal/resources/project/delete_test.go diff --git a/internal/resources/project/delete_test.go b/internal/resources/project/delete_test.go new file mode 100644 index 0000000..2a9e0f4 --- /dev/null +++ b/internal/resources/project/delete_test.go @@ -0,0 +1,155 @@ +package project + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestDeleteProjectUsesCanonicalStateID(t *testing.T) { + t.Parallel() + + var gotID string + r := newResourceWithClient(fakeProjectClient{ + delete: func(ctx context.Context, id string) error { + gotID = id + return nil + }, + }) + + diags := r.delete(context.Background(), projectModel{ID: types.StringValue("project_123")}) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if gotID != "project_123" { + t.Fatalf("DeleteProject id = %q, want canonical state id", gotID) + } +} + +func TestDeleteProjectTreatsCodedNotFoundAsSuccess(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeProjectClient{ + delete: func(ctx context.Context, id string) error { + return projectAPIError(t, http.StatusNotFound, `{"code":"not_found","message":"project not found"}`) + }, + }) + + diags := r.delete(context.Background(), projectModel{ID: types.StringValue("project_123")}) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } +} + +func TestDeleteProjectExplainsConflictWithoutForcingChildren(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeProjectClient{ + delete: func(ctx context.Context, id string) error { + return projectAPIError(t, http.StatusConflict, `{"code":"conflict","message":"project still has active resources"}`) + }, + }) + + diags := r.delete(context.Background(), projectModel{ID: types.StringValue("project_123")}) + if len(diags) != 1 { + t.Fatalf("diagnostics = %v, want one error", diags) + } + if got := diags[0].Summary(); got != "Delete Kernel Project" { + t.Fatalf("diagnostic summary = %q, want Delete Kernel Project", got) + } + detail := diags[0].Detail() + for _, want := range []string{ + "must have no active resources", + "retain at least one active project", + "will not delete child resources implicitly", + "409 Conflict", + } { + if !strings.Contains(detail, want) { + t.Fatalf("diagnostic detail = %q, want it to contain %q", detail, want) + } + } +} + +func TestDeleteProjectReturnsOtherErrors(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + err func(*testing.T) error + wantDetail string + }{ + "transport error": { + err: func(t *testing.T) error { + return errors.New("connection reset") + }, + wantDetail: "connection reset", + }, + "forbidden": { + err: func(t *testing.T) error { + return projectAPIError(t, http.StatusForbidden, `{"code":"forbidden","message":"project-scoped API keys cannot delete projects"}`) + }, + wantDetail: "403 Forbidden", + }, + "server error": { + err: func(t *testing.T) error { + return projectAPIError(t, http.StatusInternalServerError, `{"code":"db_error","message":"failed to delete project"}`) + }, + wantDetail: "500 Internal Server Error", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + deleteErr := test.err(t) + r := newResourceWithClient(fakeProjectClient{ + delete: func(ctx context.Context, id string) error { + return deleteErr + }, + }) + + diags := r.delete(context.Background(), projectModel{ID: types.StringValue("project_123")}) + if len(diags) != 1 { + t.Fatalf("diagnostics = %v, want one error", diags) + } + if got := diags[0].Summary(); got != "Delete Kernel Project" { + t.Fatalf("diagnostic summary = %q, want Delete Kernel Project", got) + } + if detail := diags[0].Detail(); !strings.Contains(detail, test.wantDetail) { + t.Fatalf("diagnostic detail = %q, want it to contain %q", detail, test.wantDetail) + } + }) + } +} + +func TestDeleteProjectRejectsMissingStateID(t *testing.T) { + t.Parallel() + + called := false + r := newResourceWithClient(fakeProjectClient{ + delete: func(ctx context.Context, id string) error { + called = true + return nil + }, + }) + + diags := r.delete(context.Background(), projectModel{ID: types.StringNull()}) + if !diags.HasError() || diags[0].Summary() != "Missing Kernel Project ID" { + t.Fatalf("diagnostics = %v, want missing-id error", diags) + } + if called { + t.Fatal("DeleteProject was called without a state id") + } +} + +func TestDeleteProjectRequiresConfiguredClient(t *testing.T) { + t.Parallel() + + diags := (&projectResource{}).delete(context.Background(), projectModel{ID: types.StringValue("project_123")}) + if !diags.HasError() || diags[0].Summary() != "Missing Kernel Client" { + t.Fatalf("diagnostics = %v, want missing-client error", diags) + } +} diff --git a/internal/resources/project/resource.go b/internal/resources/project/resource.go index 06a99c8..9503bf7 100644 --- a/internal/resources/project/resource.go +++ b/internal/resources/project/resource.go @@ -31,6 +31,7 @@ type projectClient interface { CreateProject(context.Context, kernel.ProjectNewParams) (*kernel.Project, error) GetProject(context.Context, string) (*kernel.Project, error) UpdateProject(context.Context, string, kernel.ProjectUpdateParams) (*kernel.Project, error) + DeleteProject(context.Context, string) error } type projectResource struct { @@ -194,6 +195,37 @@ func (r *projectResource) update(ctx context.Context, plan, state projectModel) return nextState, diags } +func (r *projectResource) delete(ctx context.Context, state projectModel) diag.Diagnostics { + var diags diag.Diagnostics + if r.client == nil { + addMissingClientDiagnostic(&diags) + return diags + } + + id, ok := stateProjectID(state, "delete", &diags) + if !ok { + return diags + } + + if err := r.client.DeleteProject(ctx, id); err != nil { + // Kernel also uses not_found when projects are disabled. Terraform still + // treats it as absence because the API provides no distinguishable signal. + if projectscope.IsNotFound(err) { + return diags + } + if projectDeleteConflict(err) { + diags.AddError( + "Delete Kernel Project", + "Kernel refused to delete project "+strconv.Quote(id)+". A project must have no active resources, and its organization must retain at least one active project. Terraform will not delete child resources implicitly. Underlying error: "+err.Error(), + ) + return diags + } + diags.AddError("Delete Kernel Project", err.Error()) + } + + return diags +} + func addMissingClientDiagnostic(diags *diag.Diagnostics) { diags.AddError( "Missing Kernel Client", @@ -219,6 +251,11 @@ func projectCreateFailureIsDefinite(err error) bool { return errors.As(err, &apiError) && apiError.StatusCode >= http.StatusBadRequest && apiError.StatusCode < http.StatusInternalServerError } +func projectDeleteConflict(err error) bool { + var apiError *kernel.Error + return errors.As(err, &apiError) && apiError.StatusCode == http.StatusConflict +} + func partialProjectState(project kernel.Project, plan projectModel) projectModel { if !validResponseString(project.JSON.ID.Raw(), project.JSON.ID.Valid(), project.ID) { return projectModel{} diff --git a/internal/resources/project/resource_test.go b/internal/resources/project/resource_test.go index f16e789..8a8cc82 100644 --- a/internal/resources/project/resource_test.go +++ b/internal/resources/project/resource_test.go @@ -20,6 +20,7 @@ type fakeProjectClient struct { create func(context.Context, kernel.ProjectNewParams) (*kernel.Project, error) get func(context.Context, string) (*kernel.Project, error) update func(context.Context, string, kernel.ProjectUpdateParams) (*kernel.Project, error) + delete func(context.Context, string) error } func (f fakeProjectClient) CreateProject(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { @@ -43,6 +44,13 @@ func (f fakeProjectClient) UpdateProject(ctx context.Context, id string, params return f.update(ctx, id, params) } +func (f fakeProjectClient) DeleteProject(ctx context.Context, id string) error { + if f.delete == nil { + return errors.New("unexpected delete") + } + return f.delete(ctx, id) +} + func TestCreateProjectCreatesAndFlattensState(t *testing.T) { t.Parallel() From ac4e2c30a9ccc2b688932ba6a474cee926396fc5 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:52:49 -0400 Subject: [PATCH 13/68] Add project import state helper Seed imported project state with the exact non-empty canonical ID and an unknown name so the normal Read lifecycle settles durable state. --- internal/resources/project/import_test.go | 38 +++++++++++++++++++++++ internal/resources/project/resource.go | 16 ++++++++++ 2 files changed, 54 insertions(+) create mode 100644 internal/resources/project/import_test.go diff --git a/internal/resources/project/import_test.go b/internal/resources/project/import_test.go new file mode 100644 index 0000000..36a8a3e --- /dev/null +++ b/internal/resources/project/import_test.go @@ -0,0 +1,38 @@ +package project + +import "testing" + +func TestProjectImportStateStoresCanonicalIDForRead(t *testing.T) { + t.Parallel() + + for name, id := range map[string]string{ + "canonical id": "project_123", + "preserves whitespace": " project_123 ", + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + state, diags := projectImportState(id) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if got := state.ID.ValueString(); got != id { + t.Fatalf("state id = %q, want exact import id %q", got, id) + } + if !state.Name.IsUnknown() { + t.Fatalf("state name = %v, want unknown until Read", state.Name) + } + }) + } +} + +func TestProjectImportStateRejectsEmptyID(t *testing.T) { + t.Parallel() + + state, diags := projectImportState("") + if !diags.HasError() || diags[0].Summary() != "Invalid Kernel Project Import ID" { + t.Fatalf("diagnostics = %v, want invalid import id error", diags) + } + if !state.ID.IsNull() || !state.Name.IsNull() { + t.Fatalf("state = %+v, want empty state", state) + } +} diff --git a/internal/resources/project/resource.go b/internal/resources/project/resource.go index 9503bf7..9be37d7 100644 --- a/internal/resources/project/resource.go +++ b/internal/resources/project/resource.go @@ -42,6 +42,22 @@ func newResourceWithClient(client projectClient) *projectResource { return &projectResource{client: client} } +func projectImportState(id string) (projectModel, diag.Diagnostics) { + var diags diag.Diagnostics + if id == "" { + diags.AddError( + "Invalid Kernel Project Import ID", + "Import a Kernel project using its canonical project ID.", + ) + return projectModel{}, diags + } + + return projectModel{ + ID: types.StringValue(id), + Name: types.StringUnknown(), + }, diags +} + func (r *projectResource) create(ctx context.Context, plan projectModel) (projectCreateResult, diag.Diagnostics) { var diags diag.Diagnostics if r.client == nil { From e6b75ea29b3e61f13228683ee478ca36c0035d70 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:08:59 -0400 Subject: [PATCH 14/68] Add project Framework create adapter Translate Terraform create plans into the reviewed project creation core and persist recoverable identity before uncertain diagnostics. Keep registration deferred until the complete lifecycle is wired. --- .../project/framework_create_test.go | 151 ++++++++++++++++++ internal/resources/project/resource.go | 17 ++ 2 files changed, 168 insertions(+) create mode 100644 internal/resources/project/framework_create_test.go diff --git a/internal/resources/project/framework_create_test.go b/internal/resources/project/framework_create_test.go new file mode 100644 index 0000000..1aa8266 --- /dev/null +++ b/internal/resources/project/framework_create_test.go @@ -0,0 +1,151 @@ +package project + +import ( + "context" + "net/http" + "testing" + + tfresource "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + kernel "github.com/kernel/kernel-go-sdk" +) + +func TestFrameworkCreatePersistsSuccessfulState(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeProjectClient{ + create: func(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { + project := projectForTest(t, `{"id":"project_123","name":"Project"}`) + return &project, nil + }, + }) + req, resp := frameworkCreateRequest(t, projectModel{Name: types.StringValue("Project")}) + + r.Create(context.Background(), req, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + state := frameworkCreateState(t, resp) + if got := state.ID.ValueString(); got != "project_123" { + t.Fatalf("state id = %q, want project_123", got) + } + if got := state.Name.ValueString(); got != "Project" { + t.Fatalf("state name = %q, want Project", got) + } +} + +func TestFrameworkCreatePersistsRecoverableIdentityBeforeUncertainDiagnostic(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeProjectClient{ + create: func(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { + project := projectForTest(t, `{"id":"project_123"}`) + return &project, nil + }, + }) + req, resp := frameworkCreateRequest(t, projectModel{Name: types.StringValue("Project")}) + + r.Create(context.Background(), req, resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected uncertain-create diagnostic") + } + state := frameworkCreateState(t, resp) + if got := state.ID.ValueString(); got != "project_123" { + t.Fatalf("state id = %q, want recoverable project_123", got) + } + if got := state.Name.ValueString(); got != "Project" { + t.Fatalf("state name = %q, want planned Project", got) + } +} + +func TestFrameworkCreateLeavesNoStateWithoutRecoverableIdentity(t *testing.T) { + t.Parallel() + + tests := map[string]func(*testing.T) (*kernel.Project, error){ + "uncertain empty response": func(t *testing.T) (*kernel.Project, error) { + return nil, nil + }, + "definite API failure": func(t *testing.T) (*kernel.Project, error) { + return nil, projectAPIError(t, http.StatusConflict, `{"code":"conflict"}`) + }, + } + + for name, create := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + r := newResourceWithClient(fakeProjectClient{ + create: func(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { + return create(t) + }, + }) + req, resp := frameworkCreateRequest(t, projectModel{Name: types.StringValue("Project")}) + + r.Create(context.Background(), req, resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected create diagnostic") + } + if !resp.State.Raw.IsNull() { + t.Fatalf("state = %v, want absent state", resp.State.Raw) + } + }) + } +} + +func TestFrameworkCreateRejectsMalformedPlanBeforeClientCall(t *testing.T) { + t.Parallel() + + called := false + r := newResourceWithClient(fakeProjectClient{ + create: func(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { + called = true + return nil, nil + }, + }) + var req tfresource.CreateRequest + req.Plan.Schema = projectSchema() + req.Plan.Raw = tftypes.NewValue(tftypes.String, "not a project plan") + resp := &tfresource.CreateResponse{} + resp.State.Schema = projectSchema() + resp.State.RemoveResource(context.Background()) + + r.Create(context.Background(), req, resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected malformed-plan diagnostic") + } + if called { + t.Fatal("CreateProject was called for a malformed plan") + } + if !resp.State.Raw.IsNull() { + t.Fatalf("state = %v, want absent state", resp.State.Raw) + } +} + +func frameworkCreateRequest(t *testing.T, plan projectModel) (tfresource.CreateRequest, *tfresource.CreateResponse) { + t.Helper() + ctx := context.Background() + + var req tfresource.CreateRequest + req.Plan.Schema = projectSchema() + if diags := req.Plan.Set(ctx, plan); diags.HasError() { + t.Fatalf("set create plan: %v", diags) + } + + resp := &tfresource.CreateResponse{} + resp.State.Schema = projectSchema() + resp.State.RemoveResource(ctx) + return req, resp +} + +func frameworkCreateState(t *testing.T, resp *tfresource.CreateResponse) projectModel { + t.Helper() + var state projectModel + if diags := resp.State.Get(context.Background(), &state); diags.HasError() { + t.Fatalf("get create state: %v", diags) + } + return state +} diff --git a/internal/resources/project/resource.go b/internal/resources/project/resource.go index 9be37d7..4df2de0 100644 --- a/internal/resources/project/resource.go +++ b/internal/resources/project/resource.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" + tfresource "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" kernel "github.com/kernel/kernel-go-sdk" "github.com/kernel/terraform-provider-kernel/internal/projectscope" @@ -58,6 +59,22 @@ func projectImportState(id string) (projectModel, diag.Diagnostics) { }, diags } +func (r *projectResource) Create(ctx context.Context, req tfresource.CreateRequest, resp *tfresource.CreateResponse) { + var plan projectModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + result, createDiags := r.create(ctx, plan) + persistState := result.Status == projectCreateSucceeded || + (result.Status == projectCreateUncertain && result.State.ID.ValueString() != "") + if persistState { + resp.Diagnostics.Append(resp.State.Set(ctx, result.State)...) + } + resp.Diagnostics.Append(createDiags...) +} + func (r *projectResource) create(ctx context.Context, plan projectModel) (projectCreateResult, diag.Diagnostics) { var diags diag.Diagnostics if r.client == nil { From 53727b31487f0826f5679960b749c60a5db2973a Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:24:53 -0400 Subject: [PATCH 15/68] Clarify uncertain project create recovery Separate recovery guidance for creates with and without recoverable state, and normalize empty SDK errors so Terraform always reports an actionable reason. --- .../project/framework_create_test.go | 40 ++++++-- internal/resources/project/resource.go | 75 ++++++++------- internal/resources/project/resource_test.go | 95 ++++++++++--------- 3 files changed, 123 insertions(+), 87 deletions(-) diff --git a/internal/resources/project/framework_create_test.go b/internal/resources/project/framework_create_test.go index 1aa8266..a4a0c9c 100644 --- a/internal/resources/project/framework_create_test.go +++ b/internal/resources/project/framework_create_test.go @@ -2,7 +2,9 @@ package project import ( "context" + "errors" "net/http" + "strings" "testing" tfresource "github.com/hashicorp/terraform-plugin-framework/resource" @@ -52,6 +54,13 @@ func TestFrameworkCreatePersistsRecoverableIdentityBeforeUncertainDiagnostic(t * if !resp.Diagnostics.HasError() { t.Fatal("expected uncertain-create diagnostic") } + detail := resp.Diagnostics[0].Detail() + if want := `Terraform saved project ID "project_123" in state and will plan to replace this resource.`; !strings.Contains(detail, want) { + t.Fatalf("diagnostic detail = %q, want persisted-state guidance %q", detail, want) + } + if strings.Contains(detail, "import its canonical project ID") { + t.Fatalf("diagnostic detail = %q, must not recommend import for persisted state", detail) + } state := frameworkCreateState(t, resp) if got := state.ID.ValueString(); got != "project_123" { t.Fatalf("state id = %q, want recoverable project_123", got) @@ -64,21 +73,35 @@ func TestFrameworkCreatePersistsRecoverableIdentityBeforeUncertainDiagnostic(t * func TestFrameworkCreateLeavesNoStateWithoutRecoverableIdentity(t *testing.T) { t.Parallel() - tests := map[string]func(*testing.T) (*kernel.Project, error){ - "uncertain empty response": func(t *testing.T) (*kernel.Project, error) { - return nil, nil + tests := map[string]struct { + create func(*testing.T) (*kernel.Project, error) + wantDetail string + }{ + "uncertain empty response": { + create: func(t *testing.T) (*kernel.Project, error) { + return nil, nil + }, + wantDetail: "If it exists, import its canonical project ID before applying again. If it does not exist, retry the apply.", }, - "definite API failure": func(t *testing.T) (*kernel.Project, error) { - return nil, projectAPIError(t, http.StatusConflict, `{"code":"conflict"}`) + "uncertain error without detail": { + create: func(t *testing.T) (*kernel.Project, error) { + return nil, errors.New(" ") + }, + wantDetail: "Kernel returned an error without details.", + }, + "definite API failure": { + create: func(t *testing.T) (*kernel.Project, error) { + return nil, projectAPIError(t, http.StatusConflict, `{"code":"conflict"}`) + }, }, } - for name, create := range tests { + for name, test := range tests { t.Run(name, func(t *testing.T) { t.Parallel() r := newResourceWithClient(fakeProjectClient{ create: func(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { - return create(t) + return test.create(t) }, }) req, resp := frameworkCreateRequest(t, projectModel{Name: types.StringValue("Project")}) @@ -88,6 +111,9 @@ func TestFrameworkCreateLeavesNoStateWithoutRecoverableIdentity(t *testing.T) { if !resp.Diagnostics.HasError() { t.Fatal("expected create diagnostic") } + if test.wantDetail != "" && !strings.Contains(resp.Diagnostics[0].Detail(), test.wantDetail) { + t.Fatalf("diagnostic detail = %q, want recovery guidance %q", resp.Diagnostics[0].Detail(), test.wantDetail) + } if !resp.State.Raw.IsNull() { t.Fatalf("state = %v, want absent state", resp.State.Raw) } diff --git a/internal/resources/project/resource.go b/internal/resources/project/resource.go index 4df2de0..201b6ba 100644 --- a/internal/resources/project/resource.go +++ b/internal/resources/project/resource.go @@ -15,17 +15,9 @@ import ( "github.com/kernel/terraform-provider-kernel/internal/projectscope" ) -type projectCreateStatus uint8 - -const ( - projectCreateFailed projectCreateStatus = iota - projectCreateSucceeded - projectCreateUncertain -) - type projectCreateResult struct { - State projectModel - Status projectCreateStatus + State projectModel + UncertainErr error } type projectClient interface { @@ -67,39 +59,50 @@ func (r *projectResource) Create(ctx context.Context, req tfresource.CreateReque } result, createDiags := r.create(ctx, plan) - persistState := result.Status == projectCreateSucceeded || - (result.Status == projectCreateUncertain && result.State.ID.ValueString() != "") - if persistState { + resp.Diagnostics.Append(createDiags...) + if resp.Diagnostics.HasError() { + return + } + + if result.UncertainErr == nil { resp.Diagnostics.Append(resp.State.Set(ctx, result.State)...) + return } - resp.Diagnostics.Append(createDiags...) + + projectID := result.State.ID.ValueString() + if projectID != "" { + stateDiags := resp.State.Set(ctx, result.State) + resp.Diagnostics.Append(stateDiags...) + if stateDiags.HasError() { + projectID = "" + } + } + addUncertainProjectCreateDiagnostic(&resp.Diagnostics, plan.Name.ValueString(), projectID, result.UncertainErr.Error()) } func (r *projectResource) create(ctx context.Context, plan projectModel) (projectCreateResult, diag.Diagnostics) { var diags diag.Diagnostics if r.client == nil { addMissingClientDiagnostic(&diags) - return projectCreateResult{Status: projectCreateFailed}, diags + return projectCreateResult{}, diags } params, expandDiags := expandProjectCreate(plan) diags.Append(expandDiags...) if diags.HasError() { - return projectCreateResult{Status: projectCreateFailed}, diags + return projectCreateResult{}, diags } created, err := r.client.CreateProject(ctx, params) if err != nil { if projectCreateFailureIsDefinite(err) { diags.AddError("Create Kernel Project", err.Error()) - return projectCreateResult{Status: projectCreateFailed}, diags + return projectCreateResult{}, diags } - addUncertainProjectCreateDiagnostic(&diags, plan.Name.ValueString(), err.Error()) - return projectCreateResult{Status: projectCreateUncertain}, diags + return projectCreateResult{UncertainErr: err}, diags } if created == nil { - addUncertainProjectCreateDiagnostic(&diags, plan.Name.ValueString(), "Kernel returned an empty project response.") - return projectCreateResult{Status: projectCreateUncertain}, diags + return projectCreateResult{UncertainErr: errors.New("Kernel returned an empty project response.")}, diags } state, flattenDiags := flattenProject(*created) @@ -108,24 +111,18 @@ func (r *projectResource) create(ctx context.Context, plan projectModel) (projec for _, flattenDiag := range flattenDiags { reasons = append(reasons, flattenDiag.Detail()) } - addUncertainProjectCreateDiagnostic(&diags, plan.Name.ValueString(), strings.Join(reasons, " ")) return projectCreateResult{ - State: partialProjectState(*created, plan), - Status: projectCreateUncertain, + State: partialProjectState(*created, plan), + UncertainErr: errors.New(strings.Join(reasons, " ")), }, diags } if !state.Name.Equal(plan.Name) { - addUncertainProjectCreateDiagnostic( - &diags, - plan.Name.ValueString(), - "Kernel returned project name "+strconv.Quote(state.Name.ValueString())+" instead of the requested name.", - ) return projectCreateResult{ - State: partialProjectState(*created, plan), - Status: projectCreateUncertain, + State: partialProjectState(*created, plan), + UncertainErr: errors.New("Kernel returned project name " + strconv.Quote(state.Name.ValueString()) + " instead of the requested name."), }, diags } - return projectCreateResult{State: state, Status: projectCreateSucceeded}, diags + return projectCreateResult{State: state}, diags } func (r *projectResource) read(ctx context.Context, state projectModel) (projectModel, bool, diag.Diagnostics) { @@ -299,11 +296,21 @@ func partialProjectState(project kernel.Project, plan projectModel) projectModel } } -func addUncertainProjectCreateDiagnostic(diags *diag.Diagnostics, name, reason string) { +func addUncertainProjectCreateDiagnostic(diags *diag.Diagnostics, name, projectID, reason string) { + reason = strings.TrimSpace(reason) + if reason == "" { + reason = "Kernel returned an error without details." + } + + recovery := "Check Kernel for the project. If it exists, import its canonical project ID before applying again. If it does not exist, retry the apply." + if projectID != "" { + recovery = "Terraform saved project ID " + strconv.Quote(projectID) + " in state and will plan to replace this resource. Check the project in Kernel before applying again." + } + diags.AddError( "Kernel Project Creation Outcome Uncertain", "Kernel may have created project "+strconv.Quote(name)+", but Terraform did not receive a complete confirmation. "+ - "Check Kernel for the project. If it exists and Terraform is not tracking it, import its project ID before applying again. "+ + recovery+" "+ "Reason: "+reason, ) } diff --git a/internal/resources/project/resource_test.go b/internal/resources/project/resource_test.go index 8a8cc82..56a83ba 100644 --- a/internal/resources/project/resource_test.go +++ b/internal/resources/project/resource_test.go @@ -67,8 +67,8 @@ func TestCreateProjectCreatesAndFlattensState(t *testing.T) { if diags.HasError() { t.Fatalf("unexpected diagnostics: %v", diags) } - if result.Status != projectCreateSucceeded { - t.Fatalf("create status = %v, want succeeded", result.Status) + if result.UncertainErr != nil { + t.Fatalf("uncertain error = %v, want successful create", result.UncertainErr) } if got, want := gotParams.CreateProjectRequest.Name, "Project"; got != want { t.Fatalf("create name = %q, want %q", got, want) @@ -88,16 +88,16 @@ func TestCreateProjectReturnsDiagnostics(t *testing.T) { malformedSuccess := projectForTest(t, `{"id":"project_123"}`) mismatchedSuccess := projectForTest(t, `{"id":"project_123","name":"Different"}`) tests := map[string]struct { - resource *projectResource - wantStatus projectCreateStatus - wantSummary string - wantDetail string - wantID string - wantName string + resource *projectResource + wantSummary string + wantDetail string + wantUncertain bool + wantReason string + wantID string + wantName string }{ "missing client": { resource: &projectResource{}, - wantStatus: projectCreateFailed, wantSummary: "Missing Kernel Client", wantDetail: "The Kernel provider was not configured before using the project resource.", }, @@ -107,7 +107,6 @@ func TestCreateProjectReturnsDiagnostics(t *testing.T) { return nil, conflictError }, }), - wantStatus: projectCreateFailed, wantSummary: "Create Kernel Project", wantDetail: "409 Conflict", }, @@ -117,9 +116,16 @@ func TestCreateProjectReturnsDiagnostics(t *testing.T) { return nil, errors.New("response timeout") }, }), - wantStatus: projectCreateUncertain, - wantSummary: "Kernel Project Creation Outcome Uncertain", - wantDetail: "response timeout", + wantUncertain: true, + wantReason: "response timeout", + }, + "transport error without detail": { + resource: newResourceWithClient(fakeProjectClient{ + create: func(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { + return nil, errors.New("") + }, + }), + wantUncertain: true, }, "empty success response": { resource: newResourceWithClient(fakeProjectClient{ @@ -127,9 +133,8 @@ func TestCreateProjectReturnsDiagnostics(t *testing.T) { return nil, nil }, }), - wantStatus: projectCreateUncertain, - wantSummary: "Kernel Project Creation Outcome Uncertain", - wantDetail: "Kernel returned an empty project response.", + wantUncertain: true, + wantReason: "Kernel returned an empty project response.", }, "malformed success preserves valid id": { resource: newResourceWithClient(fakeProjectClient{ @@ -137,11 +142,10 @@ func TestCreateProjectReturnsDiagnostics(t *testing.T) { return &malformedSuccess, nil }, }), - wantStatus: projectCreateUncertain, - wantSummary: "Kernel Project Creation Outcome Uncertain", - wantDetail: "missing or invalid field name", - wantID: "project_123", - wantName: "Project", + wantUncertain: true, + wantReason: "missing or invalid field name", + wantID: "project_123", + wantName: "Project", }, "mismatched success name preserves planned name": { resource: newResourceWithClient(fakeProjectClient{ @@ -149,11 +153,10 @@ func TestCreateProjectReturnsDiagnostics(t *testing.T) { return &mismatchedSuccess, nil }, }), - wantStatus: projectCreateUncertain, - wantSummary: "Kernel Project Creation Outcome Uncertain", - wantDetail: `Kernel returned project name "Different" instead of the requested name.`, - wantID: "project_123", - wantName: "Project", + wantUncertain: true, + wantReason: `Kernel returned project name "Different" instead of the requested name.`, + wantID: "project_123", + wantName: "Project", }, } @@ -161,21 +164,24 @@ func TestCreateProjectReturnsDiagnostics(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() result, diags := test.resource.create(context.Background(), projectModel{Name: types.StringValue("Project")}) - if len(diags) != 1 { - t.Fatalf("diagnostics = %v, want one error", diags) + if test.wantSummary != "" { + if len(diags) != 1 { + t.Fatalf("diagnostics = %v, want one error", diags) + } + if got := diags[0].Summary(); got != test.wantSummary { + t.Fatalf("diagnostic summary = %q, want %q", got, test.wantSummary) + } + if detail := diags[0].Detail(); !strings.Contains(detail, test.wantDetail) { + t.Fatalf("diagnostic detail = %q, want it to contain %q", detail, test.wantDetail) + } + } else if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) } - if result.Status != test.wantStatus { - t.Fatalf("create status = %v, want %v", result.Status, test.wantStatus) + if got := result.UncertainErr != nil; got != test.wantUncertain { + t.Fatalf("uncertain = %t, want %t (error: %v)", got, test.wantUncertain, result.UncertainErr) } - if got := diags[0].Summary(); got != test.wantSummary { - t.Fatalf("diagnostic summary = %q, want %q", got, test.wantSummary) - } - detail := diags[0].Detail() - if !strings.Contains(detail, test.wantDetail) { - t.Fatalf("diagnostic detail = %q, want it to contain %q", detail, test.wantDetail) - } - if test.wantStatus == projectCreateUncertain && !strings.Contains(detail, "import its project ID before applying again") { - t.Fatalf("uncertain diagnostic detail = %q, want import guidance", detail) + if test.wantReason != "" && (result.UncertainErr == nil || !strings.Contains(result.UncertainErr.Error(), test.wantReason)) { + t.Fatalf("uncertain error = %v, want it to contain %q", result.UncertainErr, test.wantReason) } if got := result.State.ID.ValueString(); got != test.wantID { t.Fatalf("state id = %q, want %q", got, test.wantID) @@ -198,18 +204,15 @@ func TestCreateProjectReportsEveryMalformedResponseField(t *testing.T) { }) result, diags := r.create(context.Background(), projectModel{Name: types.StringValue("Project")}) - if result.Status != projectCreateUncertain { - t.Fatalf("create status = %v, want uncertain", result.Status) - } - if len(diags) != 1 { - t.Fatalf("diagnostics = %v, want one uncertain-outcome error", diags) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) } for _, reason := range []string{ "missing or invalid field id", "missing or invalid field name", } { - if detail := diags[0].Detail(); !strings.Contains(detail, reason) { - t.Fatalf("diagnostic detail = %q, want it to contain %q", detail, reason) + if result.UncertainErr == nil || !strings.Contains(result.UncertainErr.Error(), reason) { + t.Fatalf("uncertain error = %v, want it to contain %q", result.UncertainErr, reason) } } } From 0b502c4c174c3676c9c899bc0a739402ec4c54f3 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:27:26 -0400 Subject: [PATCH 16/68] Add project Framework read adapter Refresh validated durable state, remove only confirmed missing projects, and preserve pre-populated Terraform state on diagnostics or malformed input. --- .../resources/project/framework_read_test.go | 145 ++++++++++++++++++ internal/resources/project/resource.go | 20 +++ 2 files changed, 165 insertions(+) create mode 100644 internal/resources/project/framework_read_test.go diff --git a/internal/resources/project/framework_read_test.go b/internal/resources/project/framework_read_test.go new file mode 100644 index 0000000..a93f913 --- /dev/null +++ b/internal/resources/project/framework_read_test.go @@ -0,0 +1,145 @@ +package project + +import ( + "context" + "errors" + "net/http" + "testing" + + tfresource "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + kernel "github.com/kernel/kernel-go-sdk" +) + +func TestFrameworkReadRefreshesState(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeProjectClient{ + get: func(ctx context.Context, id string) (*kernel.Project, error) { + project := projectForTest(t, `{"id":"project_123","name":"Renamed"}`) + return &project, nil + }, + }) + req, resp := frameworkReadRequest(t, projectModel{ + ID: types.StringValue("project_123"), + Name: types.StringValue("Original"), + }) + + r.Read(context.Background(), req, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + state := frameworkReadState(t, resp) + if got := state.ID.ValueString(); got != "project_123" { + t.Fatalf("state id = %q, want project_123", got) + } + if got := state.Name.ValueString(); got != "Renamed" { + t.Fatalf("state name = %q, want Renamed", got) + } +} + +func TestFrameworkReadRemovesConfirmedMissingProject(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeProjectClient{ + get: func(ctx context.Context, id string) (*kernel.Project, error) { + return nil, projectAPIError(t, http.StatusNotFound, `{"code":"not_found"}`) + }, + }) + req, resp := frameworkReadRequest(t, projectModel{ + ID: types.StringValue("project_123"), + Name: types.StringValue("Project"), + }) + + r.Read(context.Background(), req, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if !resp.State.Raw.IsNull() { + t.Fatalf("state = %v, want removed state", resp.State.Raw) + } +} + +func TestFrameworkReadPreservesStateOnDiagnostic(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeProjectClient{ + get: func(ctx context.Context, id string) (*kernel.Project, error) { + return nil, errors.New("connection reset") + }, + }) + want := projectModel{ + ID: types.StringValue("project_123"), + Name: types.StringValue("Project"), + } + req, resp := frameworkReadRequest(t, want) + + r.Read(context.Background(), req, resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected read diagnostic") + } + if got := frameworkReadState(t, resp); !got.ID.Equal(want.ID) || !got.Name.Equal(want.Name) { + t.Fatalf("state = %#v, want preserved %#v", got, want) + } +} + +func TestFrameworkReadRejectsMalformedStateBeforeClientCall(t *testing.T) { + t.Parallel() + + called := false + r := newResourceWithClient(fakeProjectClient{ + get: func(ctx context.Context, id string) (*kernel.Project, error) { + called = true + return nil, nil + }, + }) + var req tfresource.ReadRequest + req.State.Schema = projectSchema() + req.State.Raw = tftypes.NewValue(tftypes.String, "not project state") + resp := &tfresource.ReadResponse{} + resp.State.Schema = projectSchema() + resp.State.Raw = req.State.Raw.Copy() + + r.Read(context.Background(), req, resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected malformed-state diagnostic") + } + if called { + t.Fatal("GetProject was called for malformed state") + } + if !resp.State.Raw.Equal(req.State.Raw) { + t.Fatalf("state = %v, want preserved malformed state %v", resp.State.Raw, req.State.Raw) + } +} + +func frameworkReadRequest(t *testing.T, state projectModel) (tfresource.ReadRequest, *tfresource.ReadResponse) { + t.Helper() + ctx := context.Background() + + var req tfresource.ReadRequest + req.State.Schema = projectSchema() + if diags := req.State.Set(ctx, state); diags.HasError() { + t.Fatalf("set read request state: %v", diags) + } + + resp := &tfresource.ReadResponse{} + resp.State.Schema = projectSchema() + if diags := resp.State.Set(ctx, state); diags.HasError() { + t.Fatalf("set read response state: %v", diags) + } + return req, resp +} + +func frameworkReadState(t *testing.T, resp *tfresource.ReadResponse) projectModel { + t.Helper() + var state projectModel + if diags := resp.State.Get(context.Background(), &state); diags.HasError() { + t.Fatalf("get read state: %v", diags) + } + return state +} diff --git a/internal/resources/project/resource.go b/internal/resources/project/resource.go index 201b6ba..83ec4a3 100644 --- a/internal/resources/project/resource.go +++ b/internal/resources/project/resource.go @@ -80,6 +80,26 @@ func (r *projectResource) Create(ctx context.Context, req tfresource.CreateReque addUncertainProjectCreateDiagnostic(&resp.Diagnostics, plan.Name.ValueString(), projectID, result.UncertainErr.Error()) } +func (r *projectResource) Read(ctx context.Context, req tfresource.ReadRequest, resp *tfresource.ReadResponse) { + var state projectModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + nextState, removed, readDiags := r.read(ctx, state) + resp.Diagnostics.Append(readDiags...) + if resp.Diagnostics.HasError() { + return + } + if removed { + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, nextState)...) +} + func (r *projectResource) create(ctx context.Context, plan projectModel) (projectCreateResult, diag.Diagnostics) { var diags diag.Diagnostics if r.client == nil { From c760f767b627d022901346594d3358ba54c54ea7 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:38:28 -0400 Subject: [PATCH 17/68] Add project Framework update adapter Translate Framework update requests into the tested project update core while preserving prior state on diagnostics and no-op plans. --- .../project/framework_update_test.go | 153 ++++++++++++++++++ internal/resources/project/resource.go | 22 +++ 2 files changed, 175 insertions(+) create mode 100644 internal/resources/project/framework_update_test.go diff --git a/internal/resources/project/framework_update_test.go b/internal/resources/project/framework_update_test.go new file mode 100644 index 0000000..effaf90 --- /dev/null +++ b/internal/resources/project/framework_update_test.go @@ -0,0 +1,153 @@ +package project + +import ( + "context" + "errors" + "testing" + + tfresource "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + kernel "github.com/kernel/kernel-go-sdk" +) + +func TestFrameworkUpdateWritesValidatedState(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeProjectClient{ + update: func(ctx context.Context, id string, params kernel.ProjectUpdateParams) (*kernel.Project, error) { + project := projectForTest(t, `{"id":"project_123","name":"Renamed"}`) + return &project, nil + }, + }) + state := projectModel{ID: types.StringValue("project_123"), Name: types.StringValue("Original")} + plan := projectModel{ID: types.StringValue("project_123"), Name: types.StringValue("Renamed")} + req, resp := frameworkUpdateRequest(t, plan, state) + + r.Update(context.Background(), req, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + got := frameworkUpdateState(t, resp) + if !got.ID.Equal(state.ID) || !got.Name.Equal(plan.Name) { + t.Fatalf("state = %#v, want id %v and name %v", got, state.ID, plan.Name) + } +} + +func TestFrameworkUpdateSkipsNoOpAndPreservesState(t *testing.T) { + t.Parallel() + + called := false + r := newResourceWithClient(fakeProjectClient{ + update: func(ctx context.Context, id string, params kernel.ProjectUpdateParams) (*kernel.Project, error) { + called = true + return nil, errors.New("unexpected update") + }, + }) + state := projectModel{ID: types.StringValue("project_123"), Name: types.StringValue("Project")} + req, resp := frameworkUpdateRequest(t, state, state) + + r.Update(context.Background(), req, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if called { + t.Fatal("UpdateProject was called for a no-op plan") + } + if got := frameworkUpdateState(t, resp); !got.ID.Equal(state.ID) || !got.Name.Equal(state.Name) { + t.Fatalf("state = %#v, want preserved %#v", got, state) + } +} + +func TestFrameworkUpdatePreservesStateOnDiagnostic(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeProjectClient{ + update: func(ctx context.Context, id string, params kernel.ProjectUpdateParams) (*kernel.Project, error) { + return nil, errors.New("connection reset") + }, + }) + state := projectModel{ID: types.StringValue("project_123"), Name: types.StringValue("Original")} + plan := projectModel{ID: types.StringValue("project_123"), Name: types.StringValue("Renamed")} + req, resp := frameworkUpdateRequest(t, plan, state) + + r.Update(context.Background(), req, resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected update diagnostic") + } + if got := frameworkUpdateState(t, resp); !got.ID.Equal(state.ID) || !got.Name.Equal(state.Name) { + t.Fatalf("state = %#v, want preserved %#v", got, state) + } +} + +func TestFrameworkUpdateRejectsMalformedInputBeforeClientCall(t *testing.T) { + t.Parallel() + + for _, target := range []string{"plan", "state"} { + t.Run("malformed "+target, func(t *testing.T) { + t.Parallel() + called := false + r := newResourceWithClient(fakeProjectClient{ + update: func(ctx context.Context, id string, params kernel.ProjectUpdateParams) (*kernel.Project, error) { + called = true + return nil, nil + }, + }) + state := projectModel{ID: types.StringValue("project_123"), Name: types.StringValue("Original")} + plan := projectModel{ID: types.StringValue("project_123"), Name: types.StringValue("Renamed")} + req, resp := frameworkUpdateRequest(t, plan, state) + malformed := tftypes.NewValue(tftypes.String, "not a project value") + if target == "plan" { + req.Plan.Raw = malformed + } else { + req.State.Raw = malformed + } + + r.Update(context.Background(), req, resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected malformed-input diagnostic") + } + if called { + t.Fatal("UpdateProject was called for malformed input") + } + if got := frameworkUpdateState(t, resp); !got.ID.Equal(state.ID) || !got.Name.Equal(state.Name) { + t.Fatalf("state = %#v, want preserved %#v", got, state) + } + }) + } +} + +func frameworkUpdateRequest(t *testing.T, plan, state projectModel) (tfresource.UpdateRequest, *tfresource.UpdateResponse) { + t.Helper() + ctx := context.Background() + + var req tfresource.UpdateRequest + req.Plan.Schema = projectSchema() + if diags := req.Plan.Set(ctx, plan); diags.HasError() { + t.Fatalf("set update plan: %v", diags) + } + req.State.Schema = projectSchema() + if diags := req.State.Set(ctx, state); diags.HasError() { + t.Fatalf("set update request state: %v", diags) + } + + resp := &tfresource.UpdateResponse{} + resp.State.Schema = projectSchema() + if diags := resp.State.Set(ctx, state); diags.HasError() { + t.Fatalf("set update response state: %v", diags) + } + return req, resp +} + +func frameworkUpdateState(t *testing.T, resp *tfresource.UpdateResponse) projectModel { + t.Helper() + var state projectModel + if diags := resp.State.Get(context.Background(), &state); diags.HasError() { + t.Fatalf("get update state: %v", diags) + } + return state +} diff --git a/internal/resources/project/resource.go b/internal/resources/project/resource.go index 83ec4a3..13fb410 100644 --- a/internal/resources/project/resource.go +++ b/internal/resources/project/resource.go @@ -100,6 +100,28 @@ func (r *projectResource) Read(ctx context.Context, req tfresource.ReadRequest, resp.Diagnostics.Append(resp.State.Set(ctx, nextState)...) } +func (r *projectResource) Update(ctx context.Context, req tfresource.UpdateRequest, resp *tfresource.UpdateResponse) { + var plan projectModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + var state projectModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + nextState, updateDiags := r.update(ctx, plan, state) + resp.Diagnostics.Append(updateDiags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, nextState)...) +} + func (r *projectResource) create(ctx context.Context, plan projectModel) (projectCreateResult, diag.Diagnostics) { var diags diag.Diagnostics if r.client == nil { From 5dd2ad08344ea7b3a6e7f92d2748f5ab538ae783 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:01:39 -0400 Subject: [PATCH 18/68] Add project Framework delete adapter Decode prior project state, delegate to the non-force delete core, and preserve Terraform ownership by returning diagnostics on malformed state or API failure. --- .../project/framework_delete_test.go | 87 +++++++++++++++++++ internal/resources/project/resource.go | 10 +++ 2 files changed, 97 insertions(+) create mode 100644 internal/resources/project/framework_delete_test.go diff --git a/internal/resources/project/framework_delete_test.go b/internal/resources/project/framework_delete_test.go new file mode 100644 index 0000000..93b1487 --- /dev/null +++ b/internal/resources/project/framework_delete_test.go @@ -0,0 +1,87 @@ +package project + +import ( + "context" + "errors" + "testing" + + tfresource "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestFrameworkDeleteCallsCoreWithStateID(t *testing.T) { + t.Parallel() + + var gotID string + r := newResourceWithClient(fakeProjectClient{ + delete: func(ctx context.Context, id string) error { + gotID = id + return nil + }, + }) + req := frameworkDeleteRequest(t, projectModel{ID: types.StringValue("project_123")}) + var resp tfresource.DeleteResponse + + r.Delete(context.Background(), req, &resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if gotID != "project_123" { + t.Fatalf("DeleteProject id = %q, want project_123", gotID) + } +} + +func TestFrameworkDeletePropagatesCoreDiagnostic(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeProjectClient{ + delete: func(ctx context.Context, id string) error { + return errors.New("connection reset") + }, + }) + req := frameworkDeleteRequest(t, projectModel{ID: types.StringValue("project_123")}) + var resp tfresource.DeleteResponse + + r.Delete(context.Background(), req, &resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected delete diagnostic") + } +} + +func TestFrameworkDeleteRejectsMalformedStateBeforeClientCall(t *testing.T) { + t.Parallel() + + called := false + r := newResourceWithClient(fakeProjectClient{ + delete: func(ctx context.Context, id string) error { + called = true + return nil + }, + }) + var req tfresource.DeleteRequest + req.State.Schema = projectSchema() + req.State.Raw = tftypes.NewValue(tftypes.String, "not project state") + var resp tfresource.DeleteResponse + + r.Delete(context.Background(), req, &resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected malformed-state diagnostic") + } + if called { + t.Fatal("DeleteProject was called for malformed state") + } +} + +func frameworkDeleteRequest(t *testing.T, state projectModel) tfresource.DeleteRequest { + t.Helper() + var req tfresource.DeleteRequest + req.State.Schema = projectSchema() + if diags := req.State.Set(context.Background(), state); diags.HasError() { + t.Fatalf("set delete state: %v", diags) + } + return req +} diff --git a/internal/resources/project/resource.go b/internal/resources/project/resource.go index 13fb410..a063a99 100644 --- a/internal/resources/project/resource.go +++ b/internal/resources/project/resource.go @@ -122,6 +122,16 @@ func (r *projectResource) Update(ctx context.Context, req tfresource.UpdateReque resp.Diagnostics.Append(resp.State.Set(ctx, nextState)...) } +func (r *projectResource) Delete(ctx context.Context, req tfresource.DeleteRequest, resp *tfresource.DeleteResponse) { + var state projectModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(r.delete(ctx, state)...) +} + func (r *projectResource) create(ctx context.Context, plan projectModel) (projectCreateResult, diag.Diagnostics) { var diags diag.Diagnostics if r.client == nil { From b80c500e3b9bc808392b9f702dcbae8f9cd91ad2 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:50:52 -0400 Subject: [PATCH 19/68] Strengthen project delete diagnostic test Assert that the Framework delete adapter preserves the underlying core error detail, so the test verifies propagation rather than only the presence of an error. --- internal/resources/project/framework_delete_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/resources/project/framework_delete_test.go b/internal/resources/project/framework_delete_test.go index 93b1487..67aed69 100644 --- a/internal/resources/project/framework_delete_test.go +++ b/internal/resources/project/framework_delete_test.go @@ -3,6 +3,7 @@ package project import ( "context" "errors" + "strings" "testing" tfresource "github.com/hashicorp/terraform-plugin-framework/resource" @@ -49,6 +50,9 @@ func TestFrameworkDeletePropagatesCoreDiagnostic(t *testing.T) { if !resp.Diagnostics.HasError() { t.Fatal("expected delete diagnostic") } + if detail := resp.Diagnostics[0].Detail(); !strings.Contains(detail, "connection reset") { + t.Fatalf("diagnostic detail = %q, want it to contain %q", detail, "connection reset") + } } func TestFrameworkDeleteRejectsMalformedStateBeforeClientCall(t *testing.T) { From 23f145b3044320f96ef7a8201dfa4e5e5358d9ce Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:25:54 -0400 Subject: [PATCH 20/68] Add project Framework import adapter Write exact imported identity with an unknown name and leave API validation to the normal Read lifecycle without requiring a configured client. --- .../project/framework_import_test.go | 51 +++++++++++++++++++ internal/resources/project/resource.go | 10 ++++ 2 files changed, 61 insertions(+) create mode 100644 internal/resources/project/framework_import_test.go diff --git a/internal/resources/project/framework_import_test.go b/internal/resources/project/framework_import_test.go new file mode 100644 index 0000000..f128802 --- /dev/null +++ b/internal/resources/project/framework_import_test.go @@ -0,0 +1,51 @@ +package project + +import ( + "context" + "testing" + + tfresource "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func TestFrameworkImportWritesStateForRead(t *testing.T) { + t.Parallel() + + r := &projectResource{} + var resp tfresource.ImportStateResponse + resp.State.Schema = projectSchema() + resp.State.RemoveResource(context.Background()) + + r.ImportState(context.Background(), tfresource.ImportStateRequest{ID: "project_123"}, &resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + var state projectModel + if diags := resp.State.Get(context.Background(), &state); diags.HasError() { + t.Fatalf("get import state: %v", diags) + } + if got := state.ID.ValueString(); got != "project_123" { + t.Fatalf("state id = %q, want project_123", got) + } + if !state.Name.IsUnknown() { + t.Fatalf("state name = %v, want unknown until Read", state.Name) + } +} + +func TestFrameworkImportRejectsEmptyIDWithoutState(t *testing.T) { + t.Parallel() + + r := &projectResource{} + var resp tfresource.ImportStateResponse + resp.State.Schema = projectSchema() + resp.State.RemoveResource(context.Background()) + + r.ImportState(context.Background(), tfresource.ImportStateRequest{}, &resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected invalid import ID diagnostic") + } + if !resp.State.Raw.IsNull() { + t.Fatalf("state = %v, want absent state", resp.State.Raw) + } +} diff --git a/internal/resources/project/resource.go b/internal/resources/project/resource.go index a063a99..29ee770 100644 --- a/internal/resources/project/resource.go +++ b/internal/resources/project/resource.go @@ -132,6 +132,16 @@ func (r *projectResource) Delete(ctx context.Context, req tfresource.DeleteReque resp.Diagnostics.Append(r.delete(ctx, state)...) } +func (r *projectResource) ImportState(ctx context.Context, req tfresource.ImportStateRequest, resp *tfresource.ImportStateResponse) { + state, importDiags := projectImportState(req.ID) + resp.Diagnostics.Append(importDiags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + func (r *projectResource) create(ctx context.Context, plan projectModel) (projectCreateResult, diag.Diagnostics) { var diags diag.Diagnostics if r.client == nil { From 0637e414a368d862a42c41636dd7bb3b95e14dc6 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:46:29 -0400 Subject: [PATCH 21/68] Complete project resource interface Expose the complete Framework resource boundary with metadata, schema, durable-client configuration, and compile-time interface checks while leaving provider registration separate. --- .../project/framework_resource_test.go | 73 +++++++++++++++++++ internal/resources/project/resource.go | 35 +++++++++ 2 files changed, 108 insertions(+) create mode 100644 internal/resources/project/framework_resource_test.go diff --git a/internal/resources/project/framework_resource_test.go b/internal/resources/project/framework_resource_test.go new file mode 100644 index 0000000..7185da8 --- /dev/null +++ b/internal/resources/project/framework_resource_test.go @@ -0,0 +1,73 @@ +package project + +import ( + "context" + "testing" + + tfresource "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func TestProjectResourceMetadataAndSchema(t *testing.T) { + t.Parallel() + + r := NewResource() + var metadata tfresource.MetadataResponse + r.Metadata(context.Background(), tfresource.MetadataRequest{ProviderTypeName: "kernel"}, &metadata) + if metadata.TypeName != "kernel_project" { + t.Fatalf("type name = %q, want kernel_project", metadata.TypeName) + } + + var schema tfresource.SchemaResponse + r.Schema(context.Background(), tfresource.SchemaRequest{}, &schema) + if _, ok := schema.Schema.Attributes["id"]; !ok { + t.Fatal("project schema missing id attribute") + } + if _, ok := schema.Schema.Attributes["name"]; !ok { + t.Fatal("project schema missing name attribute") + } +} + +func TestProjectResourceConfigureAcceptsDurableClient(t *testing.T) { + t.Parallel() + + r := &projectResource{} + var resp tfresource.ConfigureResponse + r.Configure(context.Background(), tfresource.ConfigureRequest{ProviderData: fakeProjectClient{}}, &resp) + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if r.client == nil { + t.Fatal("project client was not configured") + } +} + +func TestProjectResourceConfigureRejectsUnexpectedData(t *testing.T) { + t.Parallel() + + r := &projectResource{} + var resp tfresource.ConfigureResponse + r.Configure(context.Background(), tfresource.ConfigureRequest{ProviderData: "not a client"}, &resp) + if !resp.Diagnostics.HasError() { + t.Fatal("expected unexpected-client-type error") + } + if len(resp.Diagnostics) != 1 { + t.Fatalf("diagnostics = %v, want one error", resp.Diagnostics) + } + if got := resp.Diagnostics[0].Summary(); got != "Unexpected Kernel Client Type" { + t.Fatalf("diagnostic summary = %q, want Unexpected Kernel Client Type", got) + } +} + +func TestProjectResourceConfigureAllowsNilProviderData(t *testing.T) { + t.Parallel() + + r := &projectResource{} + var resp tfresource.ConfigureResponse + r.Configure(context.Background(), tfresource.ConfigureRequest{}, &resp) + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if r.client != nil { + t.Fatal("nil provider data configured a client") + } +} diff --git a/internal/resources/project/resource.go b/internal/resources/project/resource.go index 29ee770..64ebcd7 100644 --- a/internal/resources/project/resource.go +++ b/internal/resources/project/resource.go @@ -15,6 +15,12 @@ import ( "github.com/kernel/terraform-provider-kernel/internal/projectscope" ) +var ( + _ tfresource.Resource = (*projectResource)(nil) + _ tfresource.ResourceWithConfigure = (*projectResource)(nil) + _ tfresource.ResourceWithImportState = (*projectResource)(nil) +) + type projectCreateResult struct { State projectModel UncertainErr error @@ -35,6 +41,35 @@ func newResourceWithClient(client projectClient) *projectResource { return &projectResource{client: client} } +func NewResource() tfresource.Resource { + return &projectResource{} +} + +func (r *projectResource) Metadata(ctx context.Context, req tfresource.MetadataRequest, resp *tfresource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_project" +} + +func (r *projectResource) Schema(ctx context.Context, req tfresource.SchemaRequest, resp *tfresource.SchemaResponse) { + resp.Schema = projectSchema() +} + +func (r *projectResource) Configure(ctx context.Context, req tfresource.ConfigureRequest, resp *tfresource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(projectClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Kernel Client Type", + "Expected provider data to implement the project durable client contract.", + ) + return + } + + r.client = client +} + func projectImportState(id string) (projectModel, diag.Diagnostics) { var diags diag.Diagnostics if id == "" { From 61b7fde1e76897154b05bd1ff72d2d92c581fc65 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:57:30 -0400 Subject: [PATCH 22/68] Register project resource Expose kernel_project through the provider registry and keep the README and generated resource documentation aligned with the newly public durable resource. --- README.md | 15 ++++++++++----- docs/resources/project.md | 24 ++++++++++++++++++++++++ internal/provider/provider.go | 6 ++++-- internal/provider/provider_test.go | 28 +++++++++++++++++----------- 4 files changed, 55 insertions(+), 18 deletions(-) create mode 100644 docs/resources/project.md diff --git a/README.md b/README.md index 8ee54e0..4e5117e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Terraform provider for durable Kernel infrastructure configuration. This provider manages desired state only. Browser/session runtime operations stay in the Kernel SDK and API. -## Supported In v0 +## Supported Provider configuration: @@ -12,9 +12,10 @@ Provider configuration: - `base_url` - `project_id` -Resource: +Resources: - `kernel_browser_pool` +- `kernel_project` Data sources: @@ -26,8 +27,9 @@ Data sources: Import: - `kernel_browser_pool` by canonical browser pool ID +- `kernel_project` by canonical project ID -## Not Supported In v0 +## Not Supported The provider intentionally does not manage: @@ -37,7 +39,7 @@ The provider intentionally does not manage: - logs, screenshots, or live view - runtime status or standby state - force-release or recovery operations -- API key or project resources +- API key, profile, proxy, or extension resources - extension upload, download, delete, or Chrome Store operations ## Quickstart @@ -74,11 +76,14 @@ resource "kernel_browser_pool" "example" { } ``` -Import an existing browser pool. The bare form resolves the project like create (provider default, else the API key's binding); use the project-qualified form to import from a different project: +Import existing resources by canonical ID. For browser pools, the bare form +resolves the project like create (provider default, else the API key's binding); +use the project-qualified form to import from a different project: ```sh terraform import kernel_browser_pool.example terraform import kernel_browser_pool.example / +terraform import kernel_project.example ``` ## Local Development diff --git a/docs/resources/project.md b/docs/resources/project.md new file mode 100644 index 0000000..d09ad82 --- /dev/null +++ b/docs/resources/project.md @@ -0,0 +1,24 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "kernel_project Resource - Kernel" +subcategory: "" +description: |- + Kernel project durable configuration. +--- + +# kernel_project (Resource) + +Kernel project durable configuration. + + + + +## Schema + +### Required + +- `name` (String) Project name. Must be unique within the organization. + +### Read-Only + +- `id` (String) Unique project identifier. diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 65492fd..5e3a9ee 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -9,10 +9,11 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/kernel/terraform-provider-kernel/internal/datasources/extension" "github.com/kernel/terraform-provider-kernel/internal/datasources/profile" - "github.com/kernel/terraform-provider-kernel/internal/datasources/project" + projectdatasource "github.com/kernel/terraform-provider-kernel/internal/datasources/project" "github.com/kernel/terraform-provider-kernel/internal/datasources/proxy" "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" ) var _ provider.Provider = (*kernelProvider)(nil) @@ -81,12 +82,13 @@ func (p *kernelProvider) Configure(ctx context.Context, req provider.ConfigureRe func (p *kernelProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ browserpool.NewResource, + projectresource.NewResource, } } func (p *kernelProvider) DataSources(ctx context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{ - project.NewDataSource, + projectdatasource.NewDataSource, profile.NewDataSource, proxy.NewDataSource, extension.NewDataSource, diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index cc0d388..a3fdd0d 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -28,26 +28,32 @@ func TestProviderMetadata(t *testing.T) { } } -func TestProviderRegistersBrowserPoolResource(t *testing.T) { +func TestProviderRegistersResources(t *testing.T) { t.Parallel() p := provider.New("test")() resources := p.Resources(context.Background()) - if len(resources) != 1 { - t.Fatalf("Resources length = %d, want 1", len(resources)) + if len(resources) != 2 { + t.Fatalf("Resources length = %d, want 2", len(resources)) } - var resp tfresource.MetadataResponse - resources[0]().Metadata( - context.Background(), - tfresource.MetadataRequest{ProviderTypeName: "kernel"}, - &resp, - ) - if resp.TypeName != "kernel_browser_pool" { - t.Fatalf("resource TypeName = %q, want kernel_browser_pool", resp.TypeName) + got := make(map[string]bool, len(resources)) + for _, factory := range resources { + var resp tfresource.MetadataResponse + factory().Metadata( + context.Background(), + tfresource.MetadataRequest{ProviderTypeName: "kernel"}, + &resp, + ) + got[resp.TypeName] = true } + for _, want := range []string{"kernel_browser_pool", "kernel_project"} { + if !got[want] { + t.Fatalf("missing resource %s; got %v", want, got) + } + } } func TestProviderRegistersDataSources(t *testing.T) { From f31770f2188d7fd5778f8eb5effc562c8681f891 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:28:55 -0400 Subject: [PATCH 23/68] Add project acceptance cleanup Register bounded, opt-in project cleanup for future lifecycle acceptance tests, with fast unit coverage for guard, idempotence, deadline, and error behavior. --- internal/acctest/acctest.go | 35 ++++++++++++ internal/acctest/acctest_test.go | 95 ++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/internal/acctest/acctest.go b/internal/acctest/acctest.go index 6994c3e..03b92c2 100644 --- a/internal/acctest/acctest.go +++ b/internal/acctest/acctest.go @@ -124,6 +124,41 @@ func cleanupBrowserPool(t testing.TB, client browserPoolCleaner, projectID, id s }) } +func CleanupProject(t testing.TB, id string) { + t.Helper() + + cleanupProject(t, ClientFromEnv(), id) +} + +type projectCleaner interface { + DeleteProject(context.Context, string) error +} + +func cleanupProject(t testing.TB, client projectCleaner, 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.DeleteProject(ctx, id); err != nil && !IsNotFound(err) { + t.Errorf("cleanup Kernel project %s: %v", id, err) + } + }) +} + func ClientFromEnv() kernelclient.Clients { return kernelclient.New(kernelclient.Config{ APIKey: os.Getenv(EnvAPIKey), diff --git a/internal/acctest/acctest_test.go b/internal/acctest/acctest_test.go index 3ff0944..2b246ae 100644 --- a/internal/acctest/acctest_test.go +++ b/internal/acctest/acctest_test.go @@ -17,6 +17,14 @@ type fakeBrowserPoolCleaner struct { delete func(context.Context, string, string) error } +type fakeProjectCleaner struct { + delete func(context.Context, string) error +} + +func (f fakeProjectCleaner) DeleteProject(ctx context.Context, id string) error { + return f.delete(ctx, id) +} + func (f fakeBrowserPoolCleaner) DefaultProjectID() string { return f.defaultProjectID } @@ -248,6 +256,93 @@ func TestCleanupBrowserPoolSurfacesRealDeleteErrors(t *testing.T) { } } +func TestCleanupProject(t *testing.T) { + tests := map[string]struct { + acceptance string + apiKey string + id string + deleteErr error + wantCleanups int + wantDelete bool + wantFailure bool + }{ + "empty ID is ignored": {}, + "acceptance disabled": { + apiKey: "test-key", + id: "project_123", + wantFailure: true, + }, + "API key missing": { + acceptance: "1", + id: "project_123", + wantFailure: true, + }, + "delete succeeds": { + acceptance: "1", + apiKey: "test-key", + id: "project_123", + wantCleanups: 1, + wantDelete: true, + }, + "not found is already clean": { + acceptance: "1", + apiKey: "test-key", + id: "project_123", + deleteErr: notFoundAPIError(), + wantCleanups: 1, + wantDelete: true, + }, + "delete error is reported": { + acceptance: "1", + apiKey: "test-key", + id: "project_123", + deleteErr: errors.New("connection reset"), + wantCleanups: 1, + wantDelete: true, + wantFailure: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Setenv(EnvAcceptance, test.acceptance) + t.Setenv(EnvAPIKey, test.apiKey) + + var gotID string + deleteCalled := false + deadlineSet := false + recorder := &testRecorder{TB: t} + cleanupProject(recorder, fakeProjectCleaner{ + delete: func(ctx context.Context, id string) error { + deleteCalled = true + _, deadlineSet = ctx.Deadline() + gotID = id + return test.deleteErr + }, + }, test.id) + + if got, want := len(recorder.cleanups), test.wantCleanups; got != want { + t.Fatalf("cleanupProject registered %d cleanups, want %d", got, want) + } + if test.wantCleanups == 1 { + recorder.cleanups[0]() + } + if recorder.failed != test.wantFailure { + t.Fatalf("cleanupProject failure = %t, want %t", recorder.failed, test.wantFailure) + } + if deleteCalled != test.wantDelete { + t.Fatalf("cleanupProject called delete = %t, want %t", deleteCalled, test.wantDelete) + } + if deleteCalled && !deadlineSet { + t.Fatal("cleanupProject called delete without a context deadline") + } + if test.wantDelete && gotID != test.id { + t.Fatalf("cleanup id = %q, want %q", gotID, test.id) + } + }) + } +} + func TestUniqueNameIsKernelScopedAndSafe(t *testing.T) { t.Parallel() From d3f27d5fa8394d08c815bbd181a9c8d15d82a138 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:52:14 -0400 Subject: [PATCH 24/68] Add project lifecycle acceptance test Exercise project create, rename, no-drift planning, canonical import with persisted state, and delete through Terraform while registering independent cleanup. --- .../resources/project/resource_acc_test.go | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 internal/resources/project/resource_acc_test.go diff --git a/internal/resources/project/resource_acc_test.go b/internal/resources/project/resource_acc_test.go new file mode 100644 index 0000000..67215e4 --- /dev/null +++ b/internal/resources/project/resource_acc_test.go @@ -0,0 +1,122 @@ +package project_test + +import ( + "context" + "fmt" + "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 projectResourceName = "kernel_project.test" + +func TestAccProjectLifecycle(t *testing.T) { + name := acctest.UniqueName(t, "project") + updatedName := acctest.UniqueName(t, "project-updated") + updatedConfig := testAccProjectConfig(updatedName) + var projectID string + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories(), + CheckDestroy: testAccCheckProjectDestroyed(), + Steps: []resource.TestStep{ + { + Config: testAccProjectConfig(name), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCaptureProjectID(t, projectResourceName, &projectID), + resource.TestCheckResourceAttrSet(projectResourceName, "id"), + resource.TestCheckResourceAttr(projectResourceName, "name", name), + ), + }, + { + Config: updatedConfig, + Check: resource.ComposeAggregateTestCheckFunc( + testAccCaptureProjectID(t, projectResourceName, &projectID), + resource.TestCheckResourceAttr(projectResourceName, "name", updatedName), + ), + }, + { + Config: updatedConfig, + PlanOnly: true, + }, + { + ResourceName: projectResourceName, + ImportState: true, + ImportStateVerify: true, + ImportStatePersist: true, + }, + { + Config: updatedConfig, + PlanOnly: true, + }, + }, + }) +} + +func testAccProjectConfig(name string) string { + return acctest.ProviderConfig() + fmt.Sprintf(` +resource "kernel_project" "test" { + name = %q +} +`, name) +} + +func testAccCaptureProjectID(t *testing.T, resourceName string, projectID *string) resource.TestCheckFunc { + t.Helper() + + return func(state *terraform.State) error { + id, err := projectStateID(state, resourceName) + if err != nil { + return err + } + if *projectID != "" && *projectID != id { + return fmt.Errorf("Kernel project ID changed from %s to %s", *projectID, id) + } + if *projectID == "" { + *projectID = id + acctest.CleanupProject(t, id) + } + return nil + } +} + +func testAccCheckProjectDestroyed() 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_project" || resourceState.Primary == nil || resourceState.Primary.ID == "" { + continue + } + + _, err := client.GetProject(ctx, resourceState.Primary.ID) + if acctest.IsNotFound(err) { + continue + } + if err != nil { + return fmt.Errorf("read Kernel project %s after destroy: %w", resourceState.Primary.ID, err) + } + return fmt.Errorf("Kernel project %s still exists after destroy", resourceState.Primary.ID) + } + return nil + } +} + +func projectStateID(state *terraform.State, resourceName string) (string, error) { + resourceState, ok := state.RootModule().Resources[resourceName] + if !ok { + return "", fmt.Errorf("missing resource %s in Terraform state", resourceName) + } + if resourceState.Primary == nil || resourceState.Primary.ID == "" { + return "", fmt.Errorf("missing ID for %s in Terraform state", resourceName) + } + return resourceState.Primary.ID, nil +} From 84ebe643fc203662a4e88784be4fab3b2e108997 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:37:13 -0400 Subject: [PATCH 25/68] Expand manual acceptance matrix Run browser-pool and project acceptance packages independently with step-scoped secrets, and document truthful timeout cleanup and recovery procedures. --- .github/workflows/acceptance.yml | 51 ++++++++++++++++++++++---------- README.md | 9 ++++-- docs/release.md | 10 +++++-- 3 files changed, 49 insertions(+), 21 deletions(-) diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 30cbf15..41ba489 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -11,22 +11,41 @@ concurrency: cancel-in-progress: false jobs: - browser-pool: - name: Browser pool acceptance + durable-resource: + name: ${{ matrix.name }} acceptance runs-on: ubuntu-latest - # Keep the job timeout above the go test -timeout below: the job budget - # also covers checkout/toolchain setup, and if it fires first it kills the - # test process before t.Cleanup can delete the pools it created. + strategy: + fail-fast: false + matrix: + include: + - name: Browser pool + package: ./internal/resources/browserpool + project_id_required: true + - name: Project + package: ./internal/resources/project + project_id_required: false + # Keep the job timeout above the go test timeout so Go can report the test + # timeout before the runner stops the job. Either hard timeout can bypass + # t.Cleanup, so release operators still inspect for leaked test resources. timeout-minutes: 40 env: TF_ACC: "1" KERNEL_ACC: "1" - KERNEL_API_KEY: ${{ secrets.KERNEL_API_KEY }} - KERNEL_PROJECT_ID: ${{ secrets.KERNEL_PROJECT_ID }} - KERNEL_ALT_PROJECT_ID: ${{ secrets.KERNEL_ALT_PROJECT_ID }} - KERNEL_BASE_URL: ${{ secrets.KERNEL_BASE_URL }} steps: + - name: Check API key secret + env: + KERNEL_API_KEY: ${{ secrets.KERNEL_API_KEY }} + run: | + : "${KERNEL_API_KEY:?Set the KERNEL_API_KEY repository secret before running acceptance tests.}" + + - name: Check project secret + if: matrix.project_id_required + env: + KERNEL_PROJECT_ID: ${{ secrets.KERNEL_PROJECT_ID }} + run: | + : "${KERNEL_PROJECT_ID:?Set the KERNEL_PROJECT_ID repository secret before running browser-pool acceptance tests.}" + - name: Checkout uses: actions/checkout@v6 @@ -42,10 +61,10 @@ jobs: terraform_version: "1.15.5" terraform_wrapper: false - - name: Check required secrets - run: | - : "${KERNEL_API_KEY:?Set the KERNEL_API_KEY repository secret before running acceptance tests.}" - : "${KERNEL_PROJECT_ID:?Set the KERNEL_PROJECT_ID repository secret before running acceptance tests.}" - - - name: Run browser pool acceptance tests - run: go test -count=1 -timeout=30m -v ./internal/resources/browserpool -run TestAcc + - name: Run acceptance tests + env: + KERNEL_API_KEY: ${{ secrets.KERNEL_API_KEY }} + KERNEL_BASE_URL: ${{ secrets.KERNEL_BASE_URL }} + KERNEL_PROJECT_ID: ${{ matrix.project_id_required && secrets.KERNEL_PROJECT_ID || '' }} + KERNEL_ALT_PROJECT_ID: ${{ matrix.project_id_required && secrets.KERNEL_ALT_PROJECT_ID || '' }} + run: go test -count=1 -timeout=30m -v ${{ matrix.package }} -run TestAcc diff --git a/README.md b/README.md index 4e5117e..9234545 100644 --- a/README.md +++ b/README.md @@ -142,13 +142,16 @@ export KERNEL_API_KEY="..." export KERNEL_PROJECT_ID="..." ``` -Run the browser-pool acceptance test: +Run the durable resource acceptance tests: ```sh -go test -v ./internal/resources/browserpool -run TestAccBrowserPoolLifecycle +go test -count=1 -timeout=30m -v ./internal/resources/browserpool -run TestAcc +go test -count=1 -timeout=30m -v ./internal/resources/project -run TestAcc ``` -The acceptance test creates uniquely named browser pools and uses durable `force=false` cleanup. It does not acquire browsers or perform runtime recovery. +The tests create uniquely named durable resources and register independent +cleanup. Browser-pool deletion remains `force=false`. The tests do not acquire +browsers or perform runtime recovery. ## Architecture diff --git a/docs/release.md b/docs/release.md index 3bbbe30..7a4508d 100644 --- a/docs/release.md +++ b/docs/release.md @@ -13,8 +13,14 @@ Use this checklist before publishing a Kernel Terraform provider version. - Run `go test -short -timeout=2m ./...`. - Run `go vet ./...`. - Run the complete opt-in acceptance matrix for every v1 resource and data source with real credentials before the first public release. - - The current v0 baseline command is `TF_ACC=1 KERNEL_ACC=1 KERNEL_API_KEY=... KERNEL_PROJECT_ID=... go test -count=1 -timeout=30m -v ./internal/resources/browserpool -run TestAcc`. - - Expand the manual `Acceptance` workflow as v1 resources land; keep live tests out of normal PR CI. + - Browser pools: `TF_ACC=1 KERNEL_ACC=1 KERNEL_API_KEY=... KERNEL_PROJECT_ID=... go test -count=1 -timeout=30m -v ./internal/resources/browserpool -run TestAcc`. + - Projects: `TF_ACC=1 KERNEL_ACC=1 KERNEL_API_KEY=... go test -count=1 -timeout=30m -v ./internal/resources/project -run TestAcc`. + - The manual `Acceptance` workflow runs both packages in parallel; expand its matrix as v1 resources land and keep live tests out of normal PR 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 and browser pools named `kernel-tf-*` that were created during the failed workflow run. + 2. Delete leaked browser pools first 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 a leaked project only after its child resources are gone and the organization still has another active project. + 4. Read each canonical resource ID and require a 404 before considering cleanup complete. - Verify unscoped API calls send no `X-Kernel-Project-Id` header; it is sent only when a resource-level `project_id` or the provider default resolves a project. - Confirm `terraform-registry-manifest.json` contains protocol `["6.0"]` for Terraform Plugin Framework. - Confirm the repository license before the first public release. Do not publish a public tag until `LICENSE` exists or the release owner has explicitly documented the licensing decision. From ea0bc0f542d3dfbebf41113bba3e867129816a26 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:48:45 -0400 Subject: [PATCH 26/68] Clarify project acceptance requirements --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9234545..39aecf4 100644 --- a/README.md +++ b/README.md @@ -133,12 +133,17 @@ Do not run `terraform init` just to exercise this unreleased provider through `d Acceptance tests are opt-in because they can create real Kernel resources. -Required environment: +Required for all acceptance tests: ```sh export TF_ACC=1 export KERNEL_ACC=1 export KERNEL_API_KEY="..." +``` + +Browser-pool acceptance tests additionally require: + +```sh export KERNEL_PROJECT_ID="..." ``` From af1bc97d89e1662cbba91036679227fc027179c3 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:51:38 -0400 Subject: [PATCH 27/68] Add project resource example Show the minimal durable project configuration with an explicit organization-unique name and canonical ID output. --- examples/README.md | 1 + examples/project/main.tf | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 examples/project/main.tf diff --git a/examples/README.md b/examples/README.md index 08085fd..623c052 100644 --- a/examples/README.md +++ b/examples/README.md @@ -18,4 +18,5 @@ 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. - [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. diff --git a/examples/project/main.tf b/examples/project/main.tf new file mode 100644 index 0000000..82e7af9 --- /dev/null +++ b/examples/project/main.tf @@ -0,0 +1,22 @@ +terraform { + required_providers { + kernel = { + source = "kernel/kernel" + } + } +} + +provider "kernel" {} + +variable "project_name" { + type = string + description = "Unique name for the Kernel project managed by Terraform." +} + +resource "kernel_project" "example" { + name = var.project_name +} + +output "project_id" { + value = kernel_project.example.id +} From 0edca902159b60cc8129cf011decba1324db02f2 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:13:08 -0400 Subject: [PATCH 28/68] Clarify project example credentials --- examples/README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/README.md b/examples/README.md index 623c052..ebd6116 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,10 +4,15 @@ These examples show durable Terraform configuration only. They do not acquire browsers, release browsers, invoke apps, fetch logs, take screenshots, open live view, upload extensions, or force-delete active runtime state. -Set credentials with environment variables before running Terraform: +Set the API credential before running Terraform: ```sh export KERNEL_API_KEY="..." +``` + +Project-scoped examples additionally require: + +```sh export KERNEL_PROJECT_ID="..." ``` From f8b15a72a9b818b2fce6f654a66ac2b54a7af8f5 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:42:47 -0400 Subject: [PATCH 29/68] Document extension resource contract Define the immutable upload, import, retry, and durable-state rules before implementing kernel_extension. Record the upstream API gaps that make ambiguous creates and project reconstruction unsafe. --- docs/architecture.md | 81 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index 492c4bd..416ea3a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -171,6 +171,82 @@ Durable fields with server defaults use Terraform `Optional + Computed` semantic `extension_ids` is modeled as an ordered list because Kernel persists extension `load_order`. +## Extension Resource Model + +`kernel_extension` manages an uploaded extension archive as immutable durable +content. It does not download archives, install extensions into running +browsers, or call the Chrome Web Store download endpoint. Upload disables SDK +retries because the API has no idempotency key and retrying an ambiguous success +can create a duplicate extension. + +The resource schema is deliberately small: + +- `id`: computed canonical extension ID +- `name`: optional durable name +- `project_id`: resolved project scope +- `source_sha256`: SHA-256 of the exact uploaded ZIP bytes +- `source_path`: local ZIP path used only when Terraform must upload content + +`source_path` is an optional write-only attribute. It is optional at the schema +level so an imported extension does not need a local copy of its archive, but a +resource configuration must provide it when creating or replacing an +extension. Because Terraform never stores a write-only value in plan or state +artifacts, `kernel_extension` requires Terraform 1.11 or later. + +`source_sha256` is `Optional + Computed`: normal managed configuration supplies +`filesha256(source_path)`, while import reads the server checksum when one is +available. Although both source attributes are schema-optional for import, a +create or replacement requires known configured values for both `source_path` +and `source_sha256`; omitting the checksum would make later local content +changes invisible to Terraform. Plan validation checks only attribute presence +and checksum format. Terraform's `filesha256` function reads the archive during +configuration evaluation on each plan so local content changes are observable; +the provider does not duplicate that file I/O during plan or refresh. Create +and replacement read the write-only path from resource configuration, read at +most 50 MiB plus one sentinel byte, reject an oversized archive, compute the +checksum from the accepted snapshot, and give those same bytes to the SDK. A +checksum mismatch fails before upload. Hashing and then reopening the path is +unsafe because the file can change between reads. + +`name` must match `^[A-Za-z0-9._-]{1,255}$` and must not match the API's +reserved CUID-like form `^[a-z0-9]{24}$`. The provider rejects surrounding +whitespace instead of relying on the upload endpoint to trim it and returning +state different from configuration. + +Kernel exposes no extension update endpoint. Changes to `name`, `project_id`, +or `source_sha256` therefore replace the extension. A replacement plan also +requires `source_path`, since the provider must upload the new durable object. +Changing only the local path has no remote meaning and cannot itself trigger a +replacement. + +Read uses the metadata endpoint and never downloads archive bytes. It excludes +`last_used_at` because runtime browser activity changes that field, and omits +informational `created_at` and `size_bytes` from desired resource state. A +missing checksum is tolerated only for an imported legacy record that has no +checksum in state; losing the checksum for provider-created or checksum-managed +state is a refresh diagnostic because content drift can no longer be verified. + +Import accepts the canonical extension ID and, for non-default project scope, +`/`. Import leaves `source_path` unset and settles +the remaining durable state through metadata Read. Because extension metadata +does not include project identity, Read preserves an explicitly configured, +provider-resolved, or import-qualified project scope. An unqualified import +under the API-key-bound default leaves `project_id` unset rather than guessing. +Subsequent replacement of an imported extension requires adding a local source +path and checksum. + +An upload error after the SDK call begins is treated as an ambiguous commit. +The provider does not retry or automatically adopt a possible match because +the API lacks an idempotency key and storage-enforced name uniqueness. The +diagnostic includes the project scope and expected checksum and directs the +operator to inspect matching extensions, then import the committed extension +or delete it before applying again. + +Delete uses the canonical ID. A coded `not_found` response removes the resource +from state, while `resource_in_use` produces a diagnostic directing the user to +remove durable browser-pool references first. Terraform does not mutate running +browsers or perform runtime cleanup to make deletion succeed. + ## Data Source Model Each data source should be lookup-only and side-effect free. @@ -213,6 +289,11 @@ Test types: - resource tests with fake or mocked durable client behavior where practical - opt-in acceptance tests gated by explicit environment variables +Extension archive tests use in-memory bytes and a fake durable client. Focused +unit tests cover the 50 MiB bound, checksum mismatch, same-snapshot upload, +disabled SDK retries, ambiguous-commit diagnostics, and import planning without +a local archive. Live API upload belongs only in the opt-in acceptance suite. + Acceptance tests must: - be disabled by default From 3bde47d10547896fc8537df5382dd770c2c91bb7 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:05:21 -0400 Subject: [PATCH 30/68] Add extension resource schema Define the immutable durable extension state boundary before lifecycle wiring. The schema keeps archive paths write-only, validates API-compatible names and checksums, and documents stable import behavior and unsupported name clearing. --- docs/architecture.md | 13 +- internal/resources/extension/model.go | 11 ++ internal/resources/extension/schema.go | 67 +++++++ internal/resources/extension/schema_test.go | 192 ++++++++++++++++++++ internal/resources/extension/validators.go | 45 +++++ 5 files changed, 323 insertions(+), 5 deletions(-) create mode 100644 internal/resources/extension/model.go create mode 100644 internal/resources/extension/schema.go create mode 100644 internal/resources/extension/schema_test.go create mode 100644 internal/resources/extension/validators.go diff --git a/docs/architecture.md b/docs/architecture.md index 416ea3a..05d6812 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -213,11 +213,14 @@ reserved CUID-like form `^[a-z0-9]{24}$`. The provider rejects surrounding whitespace instead of relying on the upload endpoint to trim it and returning state different from configuration. -Kernel exposes no extension update endpoint. Changes to `name`, `project_id`, -or `source_sha256` therefore replace the extension. A replacement plan also -requires `source_path`, since the provider must upload the new durable object. -Changing only the local path has no remote meaning and cannot itself trigger a -replacement. +Kernel exposes no extension update endpoint. Changes between known configured +values of `name`, `project_id`, or `source_sha256` therefore replace the +extension. Because `name` is `Optional + Computed` for stable import, omitting +it preserves the remote name and relinquishes name management; the provider +cannot use that same omission to request replacement with an unnamed extension. +Clearing a name is unsupported. A replacement plan also requires `source_path`, +since the provider must upload the new durable object. Changing only the local +path has no remote meaning and cannot itself trigger a replacement. Read uses the metadata endpoint and never downloads archive bytes. It excludes `last_used_at` because runtime browser activity changes that field, and omits diff --git a/internal/resources/extension/model.go b/internal/resources/extension/model.go new file mode 100644 index 0000000..7e6de43 --- /dev/null +++ b/internal/resources/extension/model.go @@ -0,0 +1,11 @@ +package extension + +import "github.com/hashicorp/terraform-plugin-framework/types" + +type extensionModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + ProjectID types.String `tfsdk:"project_id"` + SourcePath types.String `tfsdk:"source_path"` + SourceSHA256 types.String `tfsdk:"source_sha256"` +} diff --git a/internal/resources/extension/schema.go b/internal/resources/extension/schema.go new file mode 100644 index 0000000..2df4800 --- /dev/null +++ b/internal/resources/extension/schema.go @@ -0,0 +1,67 @@ +package extension + +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" +) + +var extensionChecksumPattern = regexp.MustCompile(`^[a-f0-9]{64}$`) + +func extensionSchema() rschema.Schema { + return rschema.Schema{ + MarkdownDescription: "Kernel uploaded extension durable configuration.", + Attributes: map[string]rschema.Attribute{ + "id": rschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Unique extension identifier.", + }, + "name": rschema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "Optional extension name. Must be unique within the project. Changing a configured name replaces the extension; omitting it preserves the remote name because the API cannot clear a name.", + PlanModifiers: immutableExtensionPlanModifiers(), + Validators: []validator.String{ + extensionNameValidator{}, + }, + }, + "project_id": rschema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "Project this extension belongs to. Defaults to the provider `project_id` when unset; when neither is set, the API key's project binding determines the project. Changing it replaces the extension.", + PlanModifiers: immutableExtensionPlanModifiers(), + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "source_path": rschema.StringAttribute{ + Optional: true, + WriteOnly: true, + MarkdownDescription: "Local path to the extension ZIP. Required when creating or replacing the extension and never stored in Terraform plan or state artifacts. Requires Terraform 1.11 or later.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "source_sha256": rschema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "Lowercase hexadecimal SHA-256 checksum of the exact extension ZIP bytes. Configure with `filesha256(source_path)`. Changing it replaces the extension.", + PlanModifiers: immutableExtensionPlanModifiers(), + Validators: []validator.String{ + stringvalidator.RegexMatches(extensionChecksumPattern, "must be a 64-character lowercase hexadecimal SHA-256 checksum"), + }, + }, + }, + } +} + +func immutableExtensionPlanModifiers() []planmodifier.String { + return []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + } +} diff --git a/internal/resources/extension/schema_test.go b/internal/resources/extension/schema_test.go new file mode 100644 index 0000000..8efbcd3 --- /dev/null +++ b/internal/resources/extension/schema_test.go @@ -0,0 +1,192 @@ +package extension + +import ( + "context" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + rschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestExtensionSchemaImplementationIsValid(t *testing.T) { + if diagnostics := extensionSchema().ValidateImplementation(context.Background()); diagnostics.HasError() { + t.Fatalf("schema validation failed: %v", diagnostics) + } +} + +func TestExtensionSchemaContainsOnlyDurableAttributes(t *testing.T) { + want := map[string]struct{}{ + "id": {}, + "name": {}, + "project_id": {}, + "source_path": {}, + "source_sha256": {}, + } + + attributes := extensionSchema().Attributes + for name := range want { + if _, ok := attributes[name]; !ok { + t.Fatalf("expected schema attribute %q", name) + } + } + for name := range attributes { + if _, ok := want[name]; !ok { + t.Fatalf("unexpected schema attribute %q", name) + } + } +} + +func TestExtensionSchemaAttributeSemantics(t *testing.T) { + schema := extensionSchema() + + id := extensionStringAttribute(t, schema, "id") + if !id.Computed || id.Optional || id.Required || id.WriteOnly { + t.Fatalf("id has unexpected flags: %#v", id) + } + + for _, name := range []string{"name", "project_id", "source_sha256"} { + attribute := extensionStringAttribute(t, schema, name) + if !attribute.Optional || !attribute.Computed || attribute.Required || attribute.WriteOnly { + t.Fatalf("%s has unexpected flags: %#v", name, attribute) + } + } + + sourcePath := extensionStringAttribute(t, schema, "source_path") + if !sourcePath.Optional || sourcePath.Computed || sourcePath.Required || !sourcePath.WriteOnly { + t.Fatalf("source_path has unexpected flags: %#v", sourcePath) + } +} + +func TestExtensionSchemaImmutableAttributePlanSemantics(t *testing.T) { + for _, name := range []string{"name", "project_id", "source_sha256"} { + attribute := extensionStringAttribute(t, extensionSchema(), name) + + planned, replace := runExtensionStringPlanModifiers( + t, + attribute, + types.StringValue("old"), + types.StringValue("new"), + types.StringValue("new"), + ) + if !replace { + t.Errorf("changing %s must require replacement", name) + } + if !planned.Equal(types.StringValue("new")) { + t.Errorf("planned %s = %v, want new", name, planned) + } + + planned, replace = runExtensionStringPlanModifiers( + t, + attribute, + types.StringValue("resolved"), + types.StringUnknown(), + types.StringNull(), + ) + if replace { + t.Errorf("unset %s must preserve imported or resolved state without replacement", name) + } + if !planned.Equal(types.StringValue("resolved")) { + t.Errorf("unset %s planned value = %v, want resolved state", name, planned) + } + } +} + +func TestExtensionSchemaNameValidation(t *testing.T) { + attribute := extensionStringAttribute(t, extensionSchema(), "name") + + for _, value := range []string{"", "bad name", "bad/name", strings.Repeat("a", 256), "abcdefghijklmnopqrstuvwx"} { + assertExtensionStringRejected(t, attribute, "name", value) + } + for _, value := range []string{"extension-1", "extension.name_1", "ABCDEFGHIJKLMNOPQRSTUVWX"} { + assertExtensionStringAccepted(t, attribute, "name", value) + } +} + +func TestExtensionSchemaChecksumValidation(t *testing.T) { + attribute := extensionStringAttribute(t, extensionSchema(), "source_sha256") + + assertExtensionStringRejected(t, attribute, "source_sha256", "") + assertExtensionStringRejected(t, attribute, "source_sha256", strings.Repeat("a", 63)) + assertExtensionStringRejected(t, attribute, "source_sha256", strings.Repeat("A", 64)) + assertExtensionStringRejected(t, attribute, "source_sha256", strings.Repeat("g", 64)) + assertExtensionStringAccepted(t, attribute, "source_sha256", strings.Repeat("a", 64)) + assertExtensionStringAccepted(t, attribute, "source_sha256", strings.Repeat("0", 64)) +} + +func TestExtensionSchemaProjectAndPathValidation(t *testing.T) { + for _, name := range []string{"project_id", "source_path"} { + attribute := extensionStringAttribute(t, extensionSchema(), name) + assertExtensionStringRejected(t, attribute, name, "") + assertExtensionStringAccepted(t, attribute, name, "value") + } +} + +func extensionStringAttribute(t *testing.T, schema rschema.Schema, name string) rschema.StringAttribute { + t.Helper() + + attribute, ok := schema.Attributes[name].(rschema.StringAttribute) + if !ok { + t.Fatalf("attribute %q has type %T, want StringAttribute", name, schema.Attributes[name]) + } + return attribute +} + +func runExtensionStringPlanModifiers(t *testing.T, attribute rschema.StringAttribute, state, plan, config types.String) (types.String, bool) { + t.Helper() + + raw := tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{}}, map[string]tftypes.Value{}) + request := planmodifier.StringRequest{ + State: tfsdk.State{Raw: raw}, + Plan: tfsdk.Plan{Raw: raw}, + StateValue: state, + PlanValue: plan, + ConfigValue: config, + } + + requiresReplace := false + for _, modifier := range attribute.PlanModifiers { + response := &planmodifier.StringResponse{PlanValue: request.PlanValue} + modifier.PlanModifyString(context.Background(), request, response) + request.PlanValue = response.PlanValue + requiresReplace = requiresReplace || response.RequiresReplace + } + return request.PlanValue, requiresReplace +} + +func assertExtensionStringRejected(t *testing.T, attribute rschema.StringAttribute, name, value string) { + t.Helper() + + diagnostics := validateExtensionString(attribute.Validators, name, types.StringValue(value)) + if !diagnostics.HasError() { + t.Fatalf("expected %s value %q to be rejected", name, value) + } +} + +func assertExtensionStringAccepted(t *testing.T, attribute rschema.StringAttribute, name, value string) { + t.Helper() + + diagnostics := validateExtensionString(attribute.Validators, name, types.StringValue(value)) + if diagnostics.HasError() { + t.Fatalf("expected %s value %q to be accepted, got %v", name, value, diagnostics) + } +} + +func validateExtensionString(validators []validator.String, name string, value types.String) diag.Diagnostics { + var diagnostics diag.Diagnostics + for _, stringValidator := range validators { + var response validator.StringResponse + stringValidator.ValidateString(context.Background(), validator.StringRequest{ + ConfigValue: value, + Path: path.Root(name), + }, &response) + diagnostics.Append(response.Diagnostics...) + } + return diagnostics +} diff --git a/internal/resources/extension/validators.go b/internal/resources/extension/validators.go new file mode 100644 index 0000000..7c6e67c --- /dev/null +++ b/internal/resources/extension/validators.go @@ -0,0 +1,45 @@ +package extension + +import ( + "context" + "fmt" + "regexp" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +const maxExtensionNameLength = 255 + +var ( + extensionNamePattern = regexp.MustCompile(fmt.Sprintf(`^[A-Za-z0-9._-]{1,%d}$`, maxExtensionNameLength)) + extensionCUIDPattern = regexp.MustCompile(`^[a-z0-9]{24}$`) +) + +var _ validator.String = extensionNameValidator{} + +type extensionNameValidator struct{} + +func (extensionNameValidator) Description(context.Context) string { + return fmt.Sprintf("name must be 1-%d characters using letters, numbers, dots, underscores, or hyphens, and must not be CUID-like", maxExtensionNameLength) +} + +func (v extensionNameValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (extensionNameValidator) ValidateString(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + value := req.ConfigValue.ValueString() + if extensionNamePattern.MatchString(value) && !extensionCUIDPattern.MatchString(value) { + return + } + + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Extension Name", + fmt.Sprintf("name must be 1-%d characters using letters, numbers, dots, underscores, or hyphens, and must not be a CUID-like string.", maxExtensionNameLength), + ) +} From 598f3b644bd668138e70a85215b29d811bd1f66b Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:19:08 -0400 Subject: [PATCH 31/68] Clarify extension replacement semantics --- docs/architecture.md | 23 ++++++++++++++------- internal/resources/extension/schema.go | 6 +++--- internal/resources/extension/schema_test.go | 14 +++++++++++++ 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 05d6812..0d3df97 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -213,14 +213,21 @@ reserved CUID-like form `^[a-z0-9]{24}$`. The provider rejects surrounding whitespace instead of relying on the upload endpoint to trim it and returning state different from configuration. -Kernel exposes no extension update endpoint. Changes between known configured -values of `name`, `project_id`, or `source_sha256` therefore replace the -extension. Because `name` is `Optional + Computed` for stable import, omitting -it preserves the remote name and relinquishes name management; the provider -cannot use that same omission to request replacement with an unnamed extension. -Clearing a name is unsupported. A replacement plan also requires `source_path`, -since the provider must upload the new durable object. Changing only the local -path has no remote meaning and cannot itself trigger a replacement. +Kernel exposes no extension update endpoint. A known configured `name`, +`project_id`, or `source_sha256` that differs from prior state therefore +replaces the extension, including a null-to-value transition after import. +Adding a name changes immutable remote metadata; adding a checksum establishes +managed content for a legacy record; and adding explicit project scope changes +identity because the provider cannot prove it matches an API-key-bound scope. +Use project-qualified import when explicit project state is required without +replacement. + +Because `name` is `Optional + Computed` for stable import, omitting it preserves +the remote name and relinquishes name management; the provider cannot use that +same omission to request replacement with an unnamed extension. Clearing a name +is unsupported. A replacement plan also requires `source_path`, since the +provider must upload the new durable object. Changing only the local path has no +remote meaning and cannot itself trigger a replacement. Read uses the metadata endpoint and never downloads archive bytes. It excludes `last_used_at` because runtime browser activity changes that field, and omits diff --git a/internal/resources/extension/schema.go b/internal/resources/extension/schema.go index 2df4800..074ebcf 100644 --- a/internal/resources/extension/schema.go +++ b/internal/resources/extension/schema.go @@ -23,7 +23,7 @@ func extensionSchema() rschema.Schema { "name": rschema.StringAttribute{ Optional: true, Computed: true, - MarkdownDescription: "Optional extension name. Must be unique within the project. Changing a configured name replaces the extension; omitting it preserves the remote name because the API cannot clear a name.", + MarkdownDescription: "Optional extension name. Must be unique within the project. Adding or changing a configured name replaces the extension; omitting it preserves the remote name because the API cannot clear a name.", PlanModifiers: immutableExtensionPlanModifiers(), Validators: []validator.String{ extensionNameValidator{}, @@ -32,7 +32,7 @@ func extensionSchema() rschema.Schema { "project_id": rschema.StringAttribute{ Optional: true, Computed: true, - MarkdownDescription: "Project this extension belongs to. Defaults to the provider `project_id` when unset; when neither is set, the API key's project binding determines the project. Changing it replaces the extension.", + MarkdownDescription: "Project this extension belongs to. Defaults to the provider `project_id` when unset; when neither is set, the API key's project binding determines the project. Adding or changing it replaces the extension.", PlanModifiers: immutableExtensionPlanModifiers(), Validators: []validator.String{ stringvalidator.LengthAtLeast(1), @@ -49,7 +49,7 @@ func extensionSchema() rschema.Schema { "source_sha256": rschema.StringAttribute{ Optional: true, Computed: true, - MarkdownDescription: "Lowercase hexadecimal SHA-256 checksum of the exact extension ZIP bytes. Configure with `filesha256(source_path)`. Changing it replaces the extension.", + MarkdownDescription: "Lowercase hexadecimal SHA-256 checksum of the exact extension ZIP bytes. Configure with `filesha256(source_path)`. Adding or changing it replaces the extension.", PlanModifiers: immutableExtensionPlanModifiers(), Validators: []validator.String{ stringvalidator.RegexMatches(extensionChecksumPattern, "must be a 64-character lowercase hexadecimal SHA-256 checksum"), diff --git a/internal/resources/extension/schema_test.go b/internal/resources/extension/schema_test.go index 8efbcd3..574147b 100644 --- a/internal/resources/extension/schema_test.go +++ b/internal/resources/extension/schema_test.go @@ -82,6 +82,20 @@ func TestExtensionSchemaImmutableAttributePlanSemantics(t *testing.T) { t.Errorf("planned %s = %v, want new", name, planned) } + planned, replace = runExtensionStringPlanModifiers( + t, + attribute, + types.StringNull(), + types.StringValue("new"), + types.StringValue("new"), + ) + if !replace { + t.Errorf("configuring %s for previously null state must require replacement", name) + } + if !planned.Equal(types.StringValue("new")) { + t.Errorf("newly configured %s planned value = %v, want new", name, planned) + } + planned, replace = runExtensionStringPlanModifiers( t, attribute, From 2daf6665ed3d2a0ccc947d8545ee8e4a0e197bfb Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:29:27 -0400 Subject: [PATCH 32/68] Add durable extension client methods --- internal/kernelclient/client.go | 8 +++++++ internal/kernelclient/client_test.go | 31 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/internal/kernelclient/client.go b/internal/kernelclient/client.go index 71f39cd..643353a 100644 --- a/internal/kernelclient/client.go +++ b/internal/kernelclient/client.go @@ -169,6 +169,14 @@ func (c Clients) GetExtension(ctx context.Context, projectID, idOrName string) ( return c.extensions.Get(ctx, idOrName, scope(projectID)...) } +func (c Clients) UploadExtension(ctx context.Context, projectID string, params kernel.ExtensionUploadParams) (*kernel.ExtensionUploadResponse, error) { + return c.extensions.Upload(ctx, params, scope(projectID, noMutationRetries())...) +} + +func (c Clients) DeleteExtension(ctx context.Context, projectID, id string) error { + return c.extensions.Delete(ctx, id, scope(projectID, noMutationRetries())...) +} + func (c Clients) CreateBrowserPool(ctx context.Context, projectID string, params kernel.BrowserPoolNewParams) (*kernel.BrowserPool, error) { return c.browserPools.New(ctx, params, scope(projectID, noMutationRetries())...) } diff --git a/internal/kernelclient/client_test.go b/internal/kernelclient/client_test.go index 23f9fe0..05e603a 100644 --- a/internal/kernelclient/client_test.go +++ b/internal/kernelclient/client_test.go @@ -361,6 +361,26 @@ func TestMutationsDisableSDKRetriesAndUseExpectedScope(t *testing.T) { return clients.DeleteBrowserPool(ctx, "project_123", "pool_123") }, }, + "extension upload": { + method: http.MethodPost, + path: "/extensions", + projectID: "project_123", + call: func(ctx context.Context, clients Clients) error { + _, err := clients.UploadExtension(ctx, "project_123", kernel.ExtensionUploadParams{ + File: strings.NewReader("extension archive"), + Name: kernel.String("Extension"), + }) + return err + }, + }, + "extension delete": { + method: http.MethodDelete, + path: "/extensions/extension_123", + projectID: "project_123", + call: func(ctx context.Context, clients Clients) error { + return clients.DeleteExtension(ctx, "project_123", "extension_123") + }, + }, } for name, test := range tests { @@ -443,6 +463,17 @@ func TestClientsDoNotExposeRuntimeBrowserPoolMethods(t *testing.T) { } } +func TestClientsDoNotExposeExtensionArchiveMethods(t *testing.T) { + t.Parallel() + + typ := reflect.TypeOf(Clients{}) + for _, name := range []string{"DownloadExtension", "DownloadExtensionFromChromeStore"} { + if _, ok := typ.MethodByName(name); ok { + t.Fatalf("Clients exposes extension archive method %s", name) + } + } +} + type capturedRequest struct { Method string Path string From f50e1a07733cd85ea20ea29d6f2b3f6df76b9152 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:50:21 -0400 Subject: [PATCH 33/68] Add extension archive snapshot --- internal/resources/extension/archive.go | 45 ++++++++++++ internal/resources/extension/archive_test.go | 77 ++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 internal/resources/extension/archive.go create mode 100644 internal/resources/extension/archive_test.go diff --git a/internal/resources/extension/archive.go b/internal/resources/extension/archive.go new file mode 100644 index 0000000..8e94b8a --- /dev/null +++ b/internal/resources/extension/archive.go @@ -0,0 +1,45 @@ +package extension + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" +) + +const maxExtensionArchiveBytes = 50 * 1024 * 1024 + +var errExtensionArchiveTooLarge = errors.New("extension archive exceeds 50 MiB limit") + +type archiveSnapshot struct { + data []byte + checksum string +} + +func loadArchiveSnapshot(path string) (archiveSnapshot, error) { + file, err := os.Open(path) + if err != nil { + return archiveSnapshot{}, fmt.Errorf("open extension archive: %w", err) + } + defer file.Close() + + return readArchiveSnapshot(file, maxExtensionArchiveBytes) +} + +func readArchiveSnapshot(reader io.Reader, maxBytes int64) (archiveSnapshot, error) { + data, err := io.ReadAll(io.LimitReader(reader, maxBytes+1)) + if err != nil { + return archiveSnapshot{}, fmt.Errorf("read extension archive: %w", err) + } + if int64(len(data)) > maxBytes { + return archiveSnapshot{}, errExtensionArchiveTooLarge + } + + sum := sha256.Sum256(data) + return archiveSnapshot{ + data: data, + checksum: hex.EncodeToString(sum[:]), + }, nil +} diff --git a/internal/resources/extension/archive_test.go b/internal/resources/extension/archive_test.go new file mode 100644 index 0000000..c74b209 --- /dev/null +++ b/internal/resources/extension/archive_test.go @@ -0,0 +1,77 @@ +package extension + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "testing/iotest" +) + +func TestLoadArchiveSnapshotReadsAndHashesSameBytes(t *testing.T) { + path := filepath.Join(t.TempDir(), "extension.zip") + data := []byte("extension archive") + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write archive: %v", err) + } + + snapshot, err := loadArchiveSnapshot(path) + if err != nil { + t.Fatalf("loadArchiveSnapshot returned error: %v", err) + } + if !bytes.Equal(snapshot.data, data) { + t.Fatalf("snapshot data = %q, want %q", snapshot.data, data) + } + if got, want := snapshot.checksum, "9602532cc2b8bc6ca42336f7d93a1f49b5272bf308cd4a6a132e0074efd76fbc"; got != want { + t.Fatalf("snapshot checksum = %q, want %q", got, want) + } +} + +func TestLoadArchiveSnapshotReturnsOpenError(t *testing.T) { + _, err := loadArchiveSnapshot(filepath.Join(t.TempDir(), "missing.zip")) + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("error = %v, want wrapped os.ErrNotExist", err) + } + if !strings.Contains(err.Error(), "open extension archive") { + t.Fatalf("error = %q, want open context", err) + } +} + +func TestReadArchiveSnapshotEnforcesSizeLimit(t *testing.T) { + tests := map[string]struct { + data string + wantErr bool + }{ + "below limit": {data: "ab"}, + "at limit": {data: "abc"}, + "over limit": {data: "abcd", wantErr: true}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + snapshot, err := readArchiveSnapshot(strings.NewReader(test.data), 3) + if test.wantErr { + if !errors.Is(err, errExtensionArchiveTooLarge) { + t.Fatalf("error = %v, want errExtensionArchiveTooLarge", err) + } + return + } + if err != nil { + t.Fatalf("readArchiveSnapshot returned error: %v", err) + } + if got, want := string(snapshot.data), test.data; got != want { + t.Fatalf("snapshot data = %q, want %q", got, want) + } + }) + } +} + +func TestReadArchiveSnapshotReturnsReadError(t *testing.T) { + want := errors.New("read failed") + _, err := readArchiveSnapshot(iotest.ErrReader(want), 3) + if !errors.Is(err, want) { + t.Fatalf("error = %v, want wrapped read failure", err) + } +} From cd520c980c46a68faececa52e0cb8b39aa54cf11 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:03:57 -0400 Subject: [PATCH 34/68] Build extension upload parameters --- internal/resources/extension/expand.go | 50 +++++++++ internal/resources/extension/expand_test.go | 109 ++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 internal/resources/extension/expand.go create mode 100644 internal/resources/extension/expand_test.go diff --git a/internal/resources/extension/expand.go b/internal/resources/extension/expand.go new file mode 100644 index 0000000..52e40c9 --- /dev/null +++ b/internal/resources/extension/expand.go @@ -0,0 +1,50 @@ +package extension + +import ( + "bytes" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + kernel "github.com/kernel/kernel-go-sdk" +) + +func expandExtensionUpload(config extensionModel, snapshot archiveSnapshot) (kernel.ExtensionUploadParams, diag.Diagnostics) { + var diags diag.Diagnostics + if config.SourceSHA256.IsNull() || config.SourceSHA256.IsUnknown() { + diags.AddAttributeError( + path.Root("source_sha256"), + "Invalid Extension Source Checksum", + "source_sha256 must be known before uploading a Kernel extension.", + ) + return kernel.ExtensionUploadParams{}, diags + } + if config.SourceSHA256.ValueString() != snapshot.checksum { + diags.AddAttributeError( + path.Root("source_sha256"), + "Extension Source Checksum Mismatch", + fmt.Sprintf( + "source_sha256 is %s, but the extension archive checksum is %s. Re-run Terraform plan after the archive stops changing.", + config.SourceSHA256.ValueString(), + snapshot.checksum, + ), + ) + return kernel.ExtensionUploadParams{}, diags + } + if config.Name.IsUnknown() { + diags.AddAttributeError( + path.Root("name"), + "Invalid Extension Name", + "name must be known before uploading a Kernel extension.", + ) + return kernel.ExtensionUploadParams{}, diags + } + + params := kernel.ExtensionUploadParams{ + File: bytes.NewReader(snapshot.data), + } + if !config.Name.IsNull() { + params.Name = kernel.String(config.Name.ValueString()) + } + return params, diags +} diff --git a/internal/resources/extension/expand_test.go b/internal/resources/extension/expand_test.go new file mode 100644 index 0000000..383740e --- /dev/null +++ b/internal/resources/extension/expand_test.go @@ -0,0 +1,109 @@ +package extension + +import ( + "io" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestExpandExtensionUploadMapsVerifiedSnapshot(t *testing.T) { + t.Parallel() + + snapshot := archiveSnapshot{ + data: []byte("extension archive"), + checksum: strings.Repeat("a", 64), + } + params, diags := expandExtensionUpload(extensionModel{ + Name: types.StringValue("Extension"), + SourceSHA256: types.StringValue(snapshot.checksum), + }, snapshot) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if !params.Name.Valid() || params.Name.Value != "Extension" { + t.Fatalf("name = %#v, want Extension", params.Name) + } + data, err := io.ReadAll(params.File) + if err != nil { + t.Fatalf("read upload file: %v", err) + } + if got, want := string(data), string(snapshot.data); got != want { + t.Fatalf("upload data = %q, want %q", got, want) + } +} + +func TestExpandExtensionUploadOmitsNullName(t *testing.T) { + t.Parallel() + + snapshot := archiveSnapshot{checksum: strings.Repeat("a", 64)} + params, diags := expandExtensionUpload(extensionModel{ + Name: types.StringNull(), + SourceSHA256: types.StringValue(snapshot.checksum), + }, snapshot) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if params.Name.Valid() { + t.Fatalf("name = %#v, want omitted", params.Name) + } +} + +func TestExpandExtensionUploadRejectsUnknownName(t *testing.T) { + t.Parallel() + + snapshot := archiveSnapshot{checksum: strings.Repeat("a", 64)} + _, diags := expandExtensionUpload(extensionModel{ + Name: types.StringUnknown(), + SourceSHA256: types.StringValue(snapshot.checksum), + }, snapshot) + assertExtensionUploadDiagnostic(t, diags, "name", "must be known") +} + +func TestExpandExtensionUploadRejectsUnknownOrNullChecksum(t *testing.T) { + t.Parallel() + + for name, value := range map[string]types.String{ + "null": types.StringNull(), + "unknown": types.StringUnknown(), + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, diags := expandExtensionUpload(extensionModel{ + Name: types.StringNull(), + SourceSHA256: value, + }, archiveSnapshot{}) + assertExtensionUploadDiagnostic(t, diags, "source_sha256", "must be known") + }) + } +} + +func TestExpandExtensionUploadRejectsChecksumMismatch(t *testing.T) { + t.Parallel() + + configured := strings.Repeat("a", 64) + actual := strings.Repeat("b", 64) + _, diags := expandExtensionUpload(extensionModel{ + Name: types.StringNull(), + SourceSHA256: types.StringValue(configured), + }, archiveSnapshot{checksum: actual}) + assertExtensionUploadDiagnostic(t, diags, "source_sha256", "archive checksum is "+actual) +} + +func assertExtensionUploadDiagnostic(t *testing.T, diags diag.Diagnostics, attribute, detail string) { + t.Helper() + + if len(diags) != 1 { + t.Fatalf("diagnostics = %v, want one error", diags) + } + if !strings.Contains(diags[0].Detail(), detail) { + t.Fatalf("diagnostic detail = %q, want it to contain %q", diags[0].Detail(), detail) + } + withPath, ok := diags[0].(diag.DiagnosticWithPath) + if !ok || !withPath.Path().Equal(path.Root(attribute)) { + t.Fatalf("diagnostic path = %v, want %s", withPath, attribute) + } +} From 17bcad9f9387598d026106fefb56f68e230f4bf3 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:15:02 -0400 Subject: [PATCH 35/68] Accumulate extension upload diagnostics --- internal/resources/extension/expand.go | 7 ++-- internal/resources/extension/expand_test.go | 46 +++++++++++++++++++-- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/internal/resources/extension/expand.go b/internal/resources/extension/expand.go index 52e40c9..2326b54 100644 --- a/internal/resources/extension/expand.go +++ b/internal/resources/extension/expand.go @@ -17,9 +17,7 @@ func expandExtensionUpload(config extensionModel, snapshot archiveSnapshot) (ker "Invalid Extension Source Checksum", "source_sha256 must be known before uploading a Kernel extension.", ) - return kernel.ExtensionUploadParams{}, diags - } - if config.SourceSHA256.ValueString() != snapshot.checksum { + } else if config.SourceSHA256.ValueString() != snapshot.checksum { diags.AddAttributeError( path.Root("source_sha256"), "Extension Source Checksum Mismatch", @@ -29,7 +27,6 @@ func expandExtensionUpload(config extensionModel, snapshot archiveSnapshot) (ker snapshot.checksum, ), ) - return kernel.ExtensionUploadParams{}, diags } if config.Name.IsUnknown() { diags.AddAttributeError( @@ -37,6 +34,8 @@ func expandExtensionUpload(config extensionModel, snapshot archiveSnapshot) (ker "Invalid Extension Name", "name must be known before uploading a Kernel extension.", ) + } + if diags.HasError() { return kernel.ExtensionUploadParams{}, diags } diff --git a/internal/resources/extension/expand_test.go b/internal/resources/extension/expand_test.go index 383740e..aa3af09 100644 --- a/internal/resources/extension/expand_test.go +++ b/internal/resources/extension/expand_test.go @@ -8,6 +8,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" ) func TestExpandExtensionUploadMapsVerifiedSnapshot(t *testing.T) { @@ -56,10 +57,11 @@ func TestExpandExtensionUploadRejectsUnknownName(t *testing.T) { t.Parallel() snapshot := archiveSnapshot{checksum: strings.Repeat("a", 64)} - _, diags := expandExtensionUpload(extensionModel{ + params, diags := expandExtensionUpload(extensionModel{ Name: types.StringUnknown(), SourceSHA256: types.StringValue(snapshot.checksum), }, snapshot) + assertEmptyExtensionUploadParams(t, params) assertExtensionUploadDiagnostic(t, diags, "name", "must be known") } @@ -72,10 +74,11 @@ func TestExpandExtensionUploadRejectsUnknownOrNullChecksum(t *testing.T) { } { t.Run(name, func(t *testing.T) { t.Parallel() - _, diags := expandExtensionUpload(extensionModel{ + params, diags := expandExtensionUpload(extensionModel{ Name: types.StringNull(), SourceSHA256: value, }, archiveSnapshot{}) + assertEmptyExtensionUploadParams(t, params) assertExtensionUploadDiagnostic(t, diags, "source_sha256", "must be known") }) } @@ -86,13 +89,40 @@ func TestExpandExtensionUploadRejectsChecksumMismatch(t *testing.T) { configured := strings.Repeat("a", 64) actual := strings.Repeat("b", 64) - _, diags := expandExtensionUpload(extensionModel{ + params, diags := expandExtensionUpload(extensionModel{ Name: types.StringNull(), SourceSHA256: types.StringValue(configured), }, archiveSnapshot{checksum: actual}) + assertEmptyExtensionUploadParams(t, params) assertExtensionUploadDiagnostic(t, diags, "source_sha256", "archive checksum is "+actual) } +func TestExpandExtensionUploadReportsAllInvalidAttributes(t *testing.T) { + t.Parallel() + + params, diags := expandExtensionUpload(extensionModel{ + Name: types.StringUnknown(), + SourceSHA256: types.StringNull(), + }, archiveSnapshot{}) + assertEmptyExtensionUploadParams(t, params) + if len(diags) != 2 { + t.Fatalf("diagnostics = %v, want two errors", diags) + } + for _, attribute := range []string{"name", "source_sha256"} { + if !extensionUploadDiagnosticHasPath(diags, path.Root(attribute)) { + t.Errorf("diagnostics = %v, want %s error", diags, attribute) + } + } +} + +func assertEmptyExtensionUploadParams(t *testing.T, params kernel.ExtensionUploadParams) { + t.Helper() + + if params.File != nil || params.Name.Valid() { + t.Fatalf("params = %#v, want no upload parameters on diagnostics", params) + } +} + func assertExtensionUploadDiagnostic(t *testing.T, diags diag.Diagnostics, attribute, detail string) { t.Helper() @@ -107,3 +137,13 @@ func assertExtensionUploadDiagnostic(t *testing.T, diags diag.Diagnostics, attri t.Fatalf("diagnostic path = %v, want %s", withPath, attribute) } } + +func extensionUploadDiagnosticHasPath(diags diag.Diagnostics, want path.Path) bool { + for _, diagnostic := range diags { + withPath, ok := diagnostic.(diag.DiagnosticWithPath) + if ok && withPath.Path().Equal(want) { + return true + } + } + return false +} From bed4babeb4cd40c9e31e42ce7391163eddf3660d Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:31:37 -0400 Subject: [PATCH 36/68] Flatten extension upload responses --- internal/resources/extension/flatten.go | 89 ++++++++++ internal/resources/extension/flatten_test.go | 167 +++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 internal/resources/extension/flatten.go create mode 100644 internal/resources/extension/flatten_test.go diff --git a/internal/resources/extension/flatten.go b/internal/resources/extension/flatten.go new file mode 100644 index 0000000..b4d47b0 --- /dev/null +++ b/internal/resources/extension/flatten.go @@ -0,0 +1,89 @@ +package extension + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" +) + +func flattenExtensionUpload(response kernel.ExtensionUploadResponse, config extensionModel, projectID string) (extensionModel, diag.Diagnostics) { + var diags diag.Diagnostics + + if !validExtensionResponseString(response.JSON.ID.Raw(), response.JSON.ID.Valid(), response.ID) { + addInvalidExtensionResponseField(&diags, "id") + } + + name := types.StringNull() + nameValid := true + if extensionResponseFieldPresent(response.JSON.Name.Raw()) { + if !validExtensionResponseString(response.JSON.Name.Raw(), response.JSON.Name.Valid(), response.Name) { + addInvalidExtensionResponseField(&diags, "name") + nameValid = false + } else { + name = types.StringValue(response.Name) + } + } + if nameValid && !name.Equal(config.Name) { + diags.AddError( + "Unexpected Kernel Extension Name", + fmt.Sprintf("Kernel returned extension name %q after uploading configured name %q.", name.ValueString(), config.Name.ValueString()), + ) + } + + checksum := types.StringNull() + if !validExtensionResponseString(response.JSON.Checksum.Raw(), response.JSON.Checksum.Valid(), response.Checksum) { + addInvalidExtensionResponseField(&diags, "checksum") + } else { + checksum = types.StringValue(response.Checksum) + if !checksum.Equal(config.SourceSHA256) { + diags.AddError( + "Unexpected Kernel Extension Checksum", + fmt.Sprintf("Kernel returned extension checksum %q after uploading source_sha256 %q.", checksum.ValueString(), config.SourceSHA256.ValueString()), + ) + } + } + + if diags.HasError() { + return extensionModel{}, diags + } + + resolvedProjectID := types.StringNull() + if projectID != "" { + resolvedProjectID = types.StringValue(projectID) + } + + return extensionModel{ + ID: types.StringValue(response.ID), + Name: name, + ProjectID: resolvedProjectID, + SourcePath: types.StringNull(), + SourceSHA256: checksum, + }, diags +} + +func extensionResponseFieldPresent(raw string) bool { + return raw != "" && strings.TrimSpace(raw) != "null" +} + +func validExtensionResponseString(raw string, valid bool, value string) bool { + if !extensionResponseFieldPresent(raw) || !valid || value == "" { + return false + } + + var decoded string + if err := json.Unmarshal([]byte(raw), &decoded); err != nil { + return false + } + return decoded == value +} + +func addInvalidExtensionResponseField(diags *diag.Diagnostics, field string) { + diags.AddError( + "Invalid Kernel Extension Response", + "Kernel returned an extension with missing or invalid field "+field+".", + ) +} diff --git a/internal/resources/extension/flatten_test.go b/internal/resources/extension/flatten_test.go new file mode 100644 index 0000000..0b2cbcf --- /dev/null +++ b/internal/resources/extension/flatten_test.go @@ -0,0 +1,167 @@ +package extension + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" +) + +func TestFlattenExtensionUploadMapsDurableState(t *testing.T) { + t.Parallel() + + checksum := strings.Repeat("a", 64) + state, diags := flattenExtensionUpload( + extensionUploadResponseForTest(t, `{ + "id":"extension_123", + "name":"Extension", + "checksum":"`+checksum+`", + "created_at":"2026-01-01T00:00:00Z", + "size_bytes":123, + "last_used_at":"2026-01-02T00:00:00Z" + }`), + extensionModel{ + Name: types.StringValue("Extension"), + SourceSHA256: types.StringValue(checksum), + }, + "project_123", + ) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if got, want := state.ID.ValueString(), "extension_123"; got != want { + t.Fatalf("id = %q, want %q", got, want) + } + if got, want := state.Name.ValueString(), "Extension"; got != want { + t.Fatalf("name = %q, want %q", got, want) + } + if got, want := state.ProjectID.ValueString(), "project_123"; got != want { + t.Fatalf("project_id = %q, want %q", got, want) + } + if got, want := state.SourceSHA256.ValueString(), checksum; got != want { + t.Fatalf("source_sha256 = %q, want %q", got, want) + } + if !state.SourcePath.IsNull() { + t.Fatalf("source_path = %v, want null write-only state", state.SourcePath) + } +} + +func TestFlattenExtensionUploadMapsNullableNameAndUnscopedProject(t *testing.T) { + t.Parallel() + + checksum := strings.Repeat("a", 64) + state, diags := flattenExtensionUpload( + extensionUploadResponseForTest(t, `{ + "id":"extension_123", + "name":null, + "checksum":"`+checksum+`", + "created_at":"2026-01-01T00:00:00Z", + "size_bytes":123, + "last_used_at":null + }`), + extensionModel{ + Name: types.StringNull(), + SourceSHA256: types.StringValue(checksum), + }, + "", + ) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if !state.Name.IsNull() { + t.Fatalf("name = %v, want null", state.Name) + } + if !state.ProjectID.IsNull() { + t.Fatalf("project_id = %v, want null", state.ProjectID) + } +} + +func TestFlattenExtensionUploadRejectsInvalidRequiredFields(t *testing.T) { + t.Parallel() + + checksum := strings.Repeat("a", 64) + tests := map[string]struct { + body string + field string + }{ + "missing id": {body: `{"checksum":"` + checksum + `"}`, field: "id"}, + "empty id": {body: `{"id":"","checksum":"` + checksum + `"}`, field: "id"}, + "missing checksum": {body: `{"id":"extension_123"}`, field: "checksum"}, + "null checksum": {body: `{"id":"extension_123","checksum":null}`, field: "checksum"}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, diags := flattenExtensionUpload( + extensionUploadResponseForTest(t, test.body), + extensionModel{Name: types.StringNull(), SourceSHA256: types.StringValue(checksum)}, + "project_123", + ) + if !extensionDiagnosticContains(diags, "Invalid Kernel Extension Response", "field "+test.field) { + t.Fatalf("diagnostics = %v, want invalid %s", diags, test.field) + } + }) + } +} + +func TestFlattenExtensionUploadRejectsResponseDrift(t *testing.T) { + t.Parallel() + + configuredChecksum := strings.Repeat("a", 64) + responseChecksum := strings.Repeat("b", 64) + tests := map[string]struct { + body string + config extensionModel + want string + }{ + "name": { + body: `{"id":"extension_123","name":"Different","checksum":"` + configuredChecksum + `"}`, + config: extensionModel{ + Name: types.StringValue("Extension"), + SourceSHA256: types.StringValue(configuredChecksum), + }, + want: "Unexpected Kernel Extension Name", + }, + "checksum": { + body: `{"id":"extension_123","checksum":"` + responseChecksum + `"}`, + config: extensionModel{ + Name: types.StringNull(), + SourceSHA256: types.StringValue(configuredChecksum), + }, + want: "Unexpected Kernel Extension Checksum", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, diags := flattenExtensionUpload(extensionUploadResponseForTest(t, test.body), test.config, "project_123") + if !extensionDiagnosticContains(diags, test.want, "") { + t.Fatalf("diagnostics = %v, want %q", diags, test.want) + } + }) + } +} + +func extensionUploadResponseForTest(t *testing.T, body string) kernel.ExtensionUploadResponse { + t.Helper() + + var response kernel.ExtensionUploadResponse + if err := json.Unmarshal([]byte(body), &response); err != nil { + t.Fatalf("unmarshal extension upload response: %v", err) + } + return response +} + +func extensionDiagnosticContains(diags diag.Diagnostics, summary, detail string) bool { + for _, diagnostic := range diags { + if diagnostic.Summary() == summary && (detail == "" || strings.Contains(diagnostic.Detail(), detail)) { + return true + } + } + return false +} From a1248c638b199757891d107d24c336e2be14057f Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:46:42 -0400 Subject: [PATCH 37/68] Classify uncertain extension uploads --- .../resources/extension/create_outcome.go | 49 +++++++ .../extension/create_outcome_test.go | 133 ++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 internal/resources/extension/create_outcome.go create mode 100644 internal/resources/extension/create_outcome_test.go diff --git a/internal/resources/extension/create_outcome.go b/internal/resources/extension/create_outcome.go new file mode 100644 index 0000000..1b28e84 --- /dev/null +++ b/internal/resources/extension/create_outcome.go @@ -0,0 +1,49 @@ +package extension + +import ( + "errors" + "net/http" + "strconv" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" +) + +func extensionUploadFailureIsDefinite(err error) bool { + var apiError *kernel.Error + return errors.As(err, &apiError) && apiError.StatusCode >= http.StatusBadRequest && apiError.StatusCode < http.StatusInternalServerError +} + +func partialExtensionState(response kernel.ExtensionUploadResponse, config extensionModel, projectID string) extensionModel { + if !validExtensionResponseString(response.JSON.ID.Raw(), response.JSON.ID.Valid(), response.ID) { + return extensionModel{} + } + + resolvedProjectID := types.StringNull() + if projectID != "" { + resolvedProjectID = types.StringValue(projectID) + } + + return extensionModel{ + ID: types.StringValue(response.ID), + Name: config.Name, + ProjectID: resolvedProjectID, + SourcePath: types.StringNull(), + SourceSHA256: config.SourceSHA256, + } +} + +func addUncertainExtensionCreateDiagnostic(diags *diag.Diagnostics, projectID, checksum, reason string) { + scope := "the API-key-bound project" + if projectID != "" { + scope = "project " + strconv.Quote(projectID) + } + + diags.AddError( + "Kernel Extension Upload Outcome Uncertain", + "Kernel may have uploaded extension content with source_sha256 "+strconv.Quote(checksum)+" in "+scope+", but Terraform did not receive a complete confirmation. "+ + "Check Kernel for a matching extension. If it exists and Terraform is not tracking it, either import its canonical extension ID or delete it before applying again. If no match exists, retry the apply. "+ + "Reason: "+reason, + ) +} diff --git a/internal/resources/extension/create_outcome_test.go b/internal/resources/extension/create_outcome_test.go new file mode 100644 index 0000000..f1823cc --- /dev/null +++ b/internal/resources/extension/create_outcome_test.go @@ -0,0 +1,133 @@ +package extension + +import ( + "errors" + "net/http" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" +) + +func TestExtensionUploadFailureIsDefinite(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + err error + want bool + }{ + "bad request": {err: &kernel.Error{StatusCode: http.StatusBadRequest}, want: true}, + "conflict": {err: &kernel.Error{StatusCode: http.StatusConflict}, want: true}, + "unprocessable": {err: &kernel.Error{StatusCode: http.StatusUnprocessableEntity}, want: true}, + "server error": {err: &kernel.Error{StatusCode: http.StatusInternalServerError}, want: false}, + "service unavailable": {err: &kernel.Error{StatusCode: http.StatusServiceUnavailable}, want: false}, + "transport error": {err: errors.New("response timeout"), want: false}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + if got := extensionUploadFailureIsDefinite(test.err); got != test.want { + t.Fatalf("extensionUploadFailureIsDefinite() = %t, want %t", got, test.want) + } + }) + } +} + +func TestPartialExtensionStatePreservesVerifiedIdentityAndConfiguration(t *testing.T) { + t.Parallel() + + checksum := strings.Repeat("a", 64) + config := extensionModel{ + Name: types.StringValue("Extension"), + SourcePath: types.StringValue("extension.zip"), + SourceSHA256: types.StringValue(checksum), + } + state := partialExtensionState( + extensionUploadResponseForTest(t, `{"id":"extension_123"}`), + config, + "project_123", + ) + + if got, want := state.ID.ValueString(), "extension_123"; got != want { + t.Fatalf("id = %q, want %q", got, want) + } + if !state.Name.Equal(config.Name) { + t.Fatalf("name = %v, want %v", state.Name, config.Name) + } + if got, want := state.ProjectID.ValueString(), "project_123"; got != want { + t.Fatalf("project_id = %q, want %q", got, want) + } + if !state.SourcePath.IsNull() { + t.Fatalf("source_path = %v, want null write-only state", state.SourcePath) + } + if !state.SourceSHA256.Equal(config.SourceSHA256) { + t.Fatalf("source_sha256 = %v, want %v", state.SourceSHA256, config.SourceSHA256) + } +} + +func TestPartialExtensionStateRejectsUnverifiedIdentity(t *testing.T) { + t.Parallel() + + for name, body := range map[string]string{ + "missing id": `{"checksum":"abc"}`, + "empty id": `{"id":""}`, + "null id": `{"id":null}`, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + state := partialExtensionState(extensionUploadResponseForTest(t, body), extensionModel{}, "project_123") + if !state.ID.IsNull() { + t.Fatalf("id = %v, want zero state", state.ID) + } + }) + } +} + +func TestPartialExtensionStateLeavesAPIKeyBoundScopeUnset(t *testing.T) { + t.Parallel() + + state := partialExtensionState( + extensionUploadResponseForTest(t, `{"id":"extension_123"}`), + extensionModel{}, + "", + ) + if !state.ProjectID.IsNull() { + t.Fatalf("project_id = %v, want null", state.ProjectID) + } +} + +func TestUncertainExtensionCreateDiagnosticIncludesRecoveryContext(t *testing.T) { + t.Parallel() + + checksum := strings.Repeat("a", 64) + tests := map[string]struct { + projectID string + scope string + }{ + "explicit project": {projectID: "project_123", scope: `project "project_123"`}, + "api key scope": {scope: "the API-key-bound project"}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + var diags diag.Diagnostics + addUncertainExtensionCreateDiagnostic(&diags, test.projectID, checksum, "response timeout") + if len(diags) != 1 { + t.Fatalf("diagnostics = %v, want one error", diags) + } + if got, want := diags[0].Summary(), "Kernel Extension Upload Outcome Uncertain"; got != want { + t.Fatalf("summary = %q, want %q", got, want) + } + detail := diags[0].Detail() + for _, want := range []string{checksum, test.scope, "import its canonical extension ID or delete it", "If no match exists, retry the apply", "response timeout"} { + if !strings.Contains(detail, want) { + t.Fatalf("detail = %q, want it to contain %q", detail, want) + } + } + }) + } +} From 493ab3e0fbcaa61c0251caf59781d6bc2bdc35c3 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:59:41 -0400 Subject: [PATCH 38/68] Prepare extension uploads from configuration --- internal/resources/extension/preflight.go | 36 +++++++ .../resources/extension/preflight_test.go | 94 +++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 internal/resources/extension/preflight.go create mode 100644 internal/resources/extension/preflight_test.go diff --git a/internal/resources/extension/preflight.go b/internal/resources/extension/preflight.go new file mode 100644 index 0000000..36522f3 --- /dev/null +++ b/internal/resources/extension/preflight.go @@ -0,0 +1,36 @@ +package extension + +import ( + "strconv" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + kernel "github.com/kernel/kernel-go-sdk" +) + +func prepareExtensionUpload(config extensionModel) (kernel.ExtensionUploadParams, diag.Diagnostics) { + var diags diag.Diagnostics + if config.SourcePath.IsNull() || config.SourcePath.IsUnknown() { + diags.AddAttributeError( + path.Root("source_path"), + "Invalid Extension Source Path", + "source_path must be configured and known before uploading a Kernel extension.", + ) + return kernel.ExtensionUploadParams{}, diags + } + + sourcePath := config.SourcePath.ValueString() + snapshot, err := loadArchiveSnapshot(sourcePath) + if err != nil { + diags.AddAttributeError( + path.Root("source_path"), + "Read Extension Archive", + "Cannot read source_path "+strconv.Quote(sourcePath)+": "+err.Error(), + ) + return kernel.ExtensionUploadParams{}, diags + } + + params, expandDiags := expandExtensionUpload(config, snapshot) + diags.Append(expandDiags...) + return params, diags +} diff --git a/internal/resources/extension/preflight_test.go b/internal/resources/extension/preflight_test.go new file mode 100644 index 0000000..89f5b31 --- /dev/null +++ b/internal/resources/extension/preflight_test.go @@ -0,0 +1,94 @@ +package extension + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestPrepareExtensionUploadUsesOneVerifiedSnapshot(t *testing.T) { + t.Parallel() + + data := []byte("extension archive") + sourcePath := writeExtensionArchiveForTest(t, data) + params, diags := prepareExtensionUpload(extensionModel{ + Name: types.StringValue("Extension"), + SourcePath: types.StringValue(sourcePath), + SourceSHA256: types.StringValue("9602532cc2b8bc6ca42336f7d93a1f49b5272bf308cd4a6a132e0074efd76fbc"), + }) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if !params.Name.Valid() || params.Name.Value != "Extension" { + t.Fatalf("name = %#v, want Extension", params.Name) + } + if err := os.WriteFile(sourcePath, []byte("replacement"), 0o600); err != nil { + t.Fatalf("replace extension archive: %v", err) + } + upload, err := io.ReadAll(params.File) + if err != nil { + t.Fatalf("read upload: %v", err) + } + if got, want := string(upload), string(data); got != want { + t.Fatalf("upload data = %q, want %q", got, want) + } +} + +func TestPrepareExtensionUploadRequiresKnownSourcePath(t *testing.T) { + t.Parallel() + + for name, sourcePath := range map[string]types.String{ + "null": types.StringNull(), + "unknown": types.StringUnknown(), + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + params, diags := prepareExtensionUpload(extensionModel{ + SourcePath: sourcePath, + SourceSHA256: types.StringValue(strings.Repeat("a", 64)), + }) + assertEmptyExtensionUploadParams(t, params) + assertExtensionUploadDiagnostic(t, diags, "source_path", "must be configured and known") + }) + } +} + +func TestPrepareExtensionUploadReportsArchiveOpenFailure(t *testing.T) { + t.Parallel() + + sourcePath := filepath.Join(t.TempDir(), "missing.zip") + params, diags := prepareExtensionUpload(extensionModel{ + SourcePath: types.StringValue(sourcePath), + SourceSHA256: types.StringValue(strings.Repeat("a", 64)), + }) + assertEmptyExtensionUploadParams(t, params) + assertExtensionUploadDiagnostic(t, diags, "source_path", "open extension archive") + if !strings.Contains(diags[0].Detail(), sourcePath) { + t.Fatalf("diagnostic detail = %q, want source path", diags[0].Detail()) + } +} + +func TestPrepareExtensionUploadRejectsChangedArchive(t *testing.T) { + t.Parallel() + + params, diags := prepareExtensionUpload(extensionModel{ + SourcePath: types.StringValue(writeExtensionArchiveForTest(t, []byte("changed"))), + SourceSHA256: types.StringValue(strings.Repeat("a", 64)), + }) + assertEmptyExtensionUploadParams(t, params) + assertExtensionUploadDiagnostic(t, diags, "source_sha256", "checksum") +} + +func writeExtensionArchiveForTest(t *testing.T, data []byte) string { + t.Helper() + + sourcePath := filepath.Join(t.TempDir(), "extension.zip") + if err := os.WriteFile(sourcePath, data, 0o600); err != nil { + t.Fatalf("write extension archive: %v", err) + } + return sourcePath +} From 2ceae11d52dae27bf2e1460b70511c4cd5197015 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:16:30 -0400 Subject: [PATCH 39/68] Orchestrate extension uploads safely --- internal/resources/extension/create.go | 58 ++++++ internal/resources/extension/create_test.go | 199 ++++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 internal/resources/extension/create.go create mode 100644 internal/resources/extension/create_test.go diff --git a/internal/resources/extension/create.go b/internal/resources/extension/create.go new file mode 100644 index 0000000..f2015d4 --- /dev/null +++ b/internal/resources/extension/create.go @@ -0,0 +1,58 @@ +package extension + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/terraform-provider-kernel/internal/projectscope" +) + +type extensionCreateStatus uint8 + +const ( + extensionCreateFailed extensionCreateStatus = iota + extensionCreateSucceeded + extensionCreateUncertain +) + +type extensionCreateResult struct { + State extensionModel + Status extensionCreateStatus +} + +type extensionUploader interface { + UploadExtension(context.Context, string, kernel.ExtensionUploadParams) (*kernel.ExtensionUploadResponse, error) +} + +func createExtension(ctx context.Context, client extensionUploader, config extensionModel, projectID string) (extensionCreateResult, diag.Diagnostics) { + params, diags := prepareExtensionUpload(config) + if diags.HasError() { + return extensionCreateResult{Status: extensionCreateFailed}, diags + } + + created, err := client.UploadExtension(ctx, projectID, params) + if err != nil { + if extensionUploadFailureIsDefinite(err) { + projectscope.AddError(&diags, "Create Kernel Extension", projectID, err) + return extensionCreateResult{Status: extensionCreateFailed}, diags + } + addUncertainExtensionCreateDiagnostic(&diags, projectID, config.SourceSHA256.ValueString(), err.Error()) + return extensionCreateResult{Status: extensionCreateUncertain}, diags + } + if created == nil { + addUncertainExtensionCreateDiagnostic(&diags, projectID, config.SourceSHA256.ValueString(), "Kernel returned an empty extension upload response.") + return extensionCreateResult{Status: extensionCreateUncertain}, diags + } + + state, flattenDiags := flattenExtensionUpload(*created, config, projectID) + if flattenDiags.HasError() { + addUncertainExtensionCreateDiagnostic(&diags, projectID, config.SourceSHA256.ValueString(), flattenDiags[0].Detail()) + return extensionCreateResult{ + State: partialExtensionState(*created, config, projectID), + Status: extensionCreateUncertain, + }, diags + } + + return extensionCreateResult{State: state, Status: extensionCreateSucceeded}, diags +} diff --git a/internal/resources/extension/create_test.go b/internal/resources/extension/create_test.go new file mode 100644 index 0000000..1fe5705 --- /dev/null +++ b/internal/resources/extension/create_test.go @@ -0,0 +1,199 @@ +package extension + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/terraform-provider-kernel/internal/kernelclient" +) + +var _ extensionUploader = kernelclient.Clients{} + +type fakeExtensionUploader struct { + upload func(context.Context, string, kernel.ExtensionUploadParams) (*kernel.ExtensionUploadResponse, error) +} + +func (f fakeExtensionUploader) UploadExtension(ctx context.Context, projectID string, params kernel.ExtensionUploadParams) (*kernel.ExtensionUploadResponse, error) { + if f.upload == nil { + return nil, errors.New("unexpected upload") + } + return f.upload(ctx, projectID, params) +} + +func TestCreateExtensionUploadsAndFlattensDurableState(t *testing.T) { + t.Parallel() + + data := []byte("extension archive") + checksum := "9602532cc2b8bc6ca42336f7d93a1f49b5272bf308cd4a6a132e0074efd76fbc" + config := extensionModel{ + Name: types.StringValue("Extension"), + SourcePath: types.StringValue(writeExtensionArchiveForTest(t, data)), + SourceSHA256: types.StringValue(checksum), + } + var gotProjectID string + result, diags := createExtension(context.Background(), fakeExtensionUploader{ + upload: func(ctx context.Context, projectID string, params kernel.ExtensionUploadParams) (*kernel.ExtensionUploadResponse, error) { + gotProjectID = projectID + if !params.Name.Valid() || params.Name.Value != "Extension" { + t.Fatalf("name = %#v, want Extension", params.Name) + } + upload, err := io.ReadAll(params.File) + if err != nil { + t.Fatalf("read upload: %v", err) + } + if got, want := string(upload), string(data); got != want { + t.Fatalf("upload data = %q, want %q", got, want) + } + response := extensionUploadResponseForTest(t, `{"id":"extension_123","name":"Extension","checksum":"`+checksum+`"}`) + return &response, nil + }, + }, config, "project_123") + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if result.Status != extensionCreateSucceeded { + t.Fatalf("status = %v, want succeeded", result.Status) + } + if got, want := gotProjectID, "project_123"; got != want { + t.Fatalf("project id = %q, want %q", got, want) + } + if got, want := result.State.ID.ValueString(), "extension_123"; got != want { + t.Fatalf("state id = %q, want %q", got, want) + } + if !result.State.SourcePath.IsNull() { + t.Fatalf("source_path = %v, want null write-only state", result.State.SourcePath) + } +} + +func TestCreateExtensionStopsBeforeUploadOnPreflightFailure(t *testing.T) { + t.Parallel() + + called := false + result, diags := createExtension(context.Background(), fakeExtensionUploader{ + upload: func(ctx context.Context, projectID string, params kernel.ExtensionUploadParams) (*kernel.ExtensionUploadResponse, error) { + called = true + return nil, nil + }, + }, extensionModel{ + SourcePath: types.StringUnknown(), + SourceSHA256: types.StringValue(strings.Repeat("a", 64)), + }, "project_123") + if !diags.HasError() { + t.Fatal("expected preflight diagnostic") + } + if result.Status != extensionCreateFailed { + t.Fatalf("status = %v, want failed", result.Status) + } + if called { + t.Fatal("upload called after preflight failure") + } +} + +func TestCreateExtensionClassifiesDefiniteAPIRejection(t *testing.T) { + t.Parallel() + + config := extensionConfigForCreateTest(t) + result, diags := createExtension(context.Background(), fakeExtensionUploader{ + upload: func(ctx context.Context, projectID string, params kernel.ExtensionUploadParams) (*kernel.ExtensionUploadResponse, error) { + return nil, extensionAPIErrorForTest(t, http.StatusConflict, `{}`) + }, + }, config, "") + if result.Status != extensionCreateFailed { + t.Fatalf("status = %v, want failed", result.Status) + } + if !extensionDiagnosticContains(diags, "Create Kernel Extension", "409") { + t.Fatalf("diagnostics = %v, want definite create failure", diags) + } +} + +func TestCreateExtensionClassifiesUncertainOutcomes(t *testing.T) { + t.Parallel() + + checksum := "9602532cc2b8bc6ca42336f7d93a1f49b5272bf308cd4a6a132e0074efd76fbc" + tests := map[string]struct { + response *kernel.ExtensionUploadResponse + err error + reason string + wantID string + }{ + "transport failure": { + err: errors.New("response timeout"), + reason: "response timeout", + }, + "server failure": { + err: extensionAPIErrorForTest(t, http.StatusInternalServerError, `{}`), + reason: "500", + }, + "empty success response": { + reason: "empty extension upload response", + }, + "malformed success preserves valid id": { + response: extensionUploadResponsePointerForTest(t, `{"id":"extension_123"}`), + reason: "missing or invalid field checksum", + wantID: "extension_123", + }, + "malformed success rejects invalid id": { + response: extensionUploadResponsePointerForTest(t, `{"checksum":"`+checksum+`"}`), + reason: "missing or invalid field id", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + result, diags := createExtension(context.Background(), fakeExtensionUploader{ + upload: func(ctx context.Context, projectID string, params kernel.ExtensionUploadParams) (*kernel.ExtensionUploadResponse, error) { + return test.response, test.err + }, + }, extensionConfigForCreateTest(t), "project_123") + if result.Status != extensionCreateUncertain { + t.Fatalf("status = %v, want uncertain", result.Status) + } + if !extensionDiagnosticContains(diags, "Kernel Extension Upload Outcome Uncertain", test.reason) { + t.Fatalf("diagnostics = %v, want uncertain reason %q", diags, test.reason) + } + if got := result.State.ID.ValueString(); got != test.wantID { + t.Fatalf("state id = %q, want %q", got, test.wantID) + } + }) + } +} + +func extensionConfigForCreateTest(t *testing.T) extensionModel { + t.Helper() + return extensionModel{ + Name: types.StringNull(), + SourcePath: types.StringValue(writeExtensionArchiveForTest(t, []byte("extension archive"))), + SourceSHA256: types.StringValue("9602532cc2b8bc6ca42336f7d93a1f49b5272bf308cd4a6a132e0074efd76fbc"), + } +} + +func extensionUploadResponsePointerForTest(t *testing.T, body string) *kernel.ExtensionUploadResponse { + t.Helper() + response := extensionUploadResponseForTest(t, body) + return &response +} + +func extensionAPIErrorForTest(t *testing.T, status int, body string) *kernel.Error { + t.Helper() + + var apiError kernel.Error + if err := json.Unmarshal([]byte(body), &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: "/extensions"}, + } + apiError.Response = &http.Response{StatusCode: status, Status: http.StatusText(status)} + return &apiError +} From 4ef6d3df71851b09333c6c59d2a6b7e2ec4cd2ac Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:41:47 -0400 Subject: [PATCH 40/68] Wire extension uploads into Terraform Create --- .../resources/extension/framework_create.go | 58 ++++ .../extension/framework_create_test.go | 319 ++++++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 internal/resources/extension/framework_create.go create mode 100644 internal/resources/extension/framework_create_test.go diff --git a/internal/resources/extension/framework_create.go b/internal/resources/extension/framework_create.go new file mode 100644 index 0000000..50b0cef --- /dev/null +++ b/internal/resources/extension/framework_create.go @@ -0,0 +1,58 @@ +package extension + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/kernel/terraform-provider-kernel/internal/projectscope" +) + +func createExtensionResource(ctx context.Context, client extensionUploader, defaultProjectID string, req resource.CreateRequest, resp *resource.CreateResponse) { + var config extensionModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + if client == nil { + resp.Diagnostics.AddError( + "Missing Kernel Client", + "The Kernel provider was not configured before using the extension resource.", + ) + return + } + + if config.Name.IsUnknown() || config.ProjectID.IsUnknown() || config.SourceSHA256.IsUnknown() { + var plan extensionModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + if config.Name.IsUnknown() { + config.Name = plan.Name + } + if config.ProjectID.IsUnknown() { + config.ProjectID = plan.ProjectID + } + if config.SourceSHA256.IsUnknown() { + config.SourceSHA256 = plan.SourceSHA256 + } + } + if config.ProjectID.IsUnknown() { + resp.Diagnostics.AddAttributeError( + path.Root("project_id"), + "Unknown Kernel Project ID", + "project_id must be known before uploading a Kernel extension. Re-run the operation after the referenced value is available.", + ) + return + } + + projectID := projectscope.Resolve(config.ProjectID, defaultProjectID) + result, createDiags := createExtension(ctx, client, config, projectID) + persistState := result.Status == extensionCreateSucceeded || + (result.Status == extensionCreateUncertain && result.State.ID.ValueString() != "") + if persistState { + resp.Diagnostics.Append(resp.State.Set(ctx, result.State)...) + } + resp.Diagnostics.Append(createDiags...) +} diff --git a/internal/resources/extension/framework_create_test.go b/internal/resources/extension/framework_create_test.go new file mode 100644 index 0000000..b32cf26 --- /dev/null +++ b/internal/resources/extension/framework_create_test.go @@ -0,0 +1,319 @@ +package extension + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "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" +) + +func TestFrameworkCreateExtensionPersistsSuccessfulState(t *testing.T) { + t.Parallel() + + config := extensionFrameworkConfigForTest(t) + var gotProjectID string + client := fakeExtensionUploader{ + upload: func(ctx context.Context, projectID string, params kernel.ExtensionUploadParams) (*kernel.ExtensionUploadResponse, error) { + gotProjectID = projectID + response := extensionUploadResponseForTest(t, `{"id":"extension_123","checksum":"`+config.SourceSHA256.ValueString()+`"}`) + return &response, nil + }, + } + req, resp := frameworkExtensionCreateRequest(t, config) + + createExtensionResource(context.Background(), client, "project_default", req, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if got, want := gotProjectID, "project_default"; got != want { + t.Fatalf("project id = %q, want %q", got, want) + } + state := frameworkExtensionCreateState(t, resp) + if got, want := state.ID.ValueString(), "extension_123"; got != want { + t.Fatalf("state id = %q, want %q", got, want) + } + if !state.ProjectID.Equal(types.StringValue("project_default")) { + t.Fatalf("project_id = %v, want project_default", state.ProjectID) + } + if !state.SourcePath.IsNull() { + t.Fatalf("source_path = %v, want null write-only state", state.SourcePath) + } +} + +func TestFrameworkCreateExtensionExplicitProjectOverridesDefault(t *testing.T) { + t.Parallel() + + config := extensionFrameworkConfigForTest(t) + config.ProjectID = types.StringValue("project_explicit") + var gotProjectID string + client := fakeExtensionUploader{ + upload: func(ctx context.Context, projectID string, params kernel.ExtensionUploadParams) (*kernel.ExtensionUploadResponse, error) { + gotProjectID = projectID + response := extensionUploadResponseForTest(t, `{"id":"extension_123","checksum":"`+config.SourceSHA256.ValueString()+`"}`) + return &response, nil + }, + } + req, resp := frameworkExtensionCreateRequest(t, config) + + createExtensionResource(context.Background(), client, "project_default", req, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if got, want := gotProjectID, "project_explicit"; got != want { + t.Fatalf("project id = %q, want %q", got, want) + } +} + +func TestFrameworkCreateExtensionUsesKnownPlannedProject(t *testing.T) { + t.Parallel() + + config := extensionFrameworkConfigForTest(t) + config.ProjectID = types.StringUnknown() + var gotProjectID string + client := fakeExtensionUploader{ + upload: func(ctx context.Context, projectID string, params kernel.ExtensionUploadParams) (*kernel.ExtensionUploadResponse, error) { + gotProjectID = projectID + response := extensionUploadResponseForTest(t, `{"id":"extension_123","checksum":"`+config.SourceSHA256.ValueString()+`"}`) + return &response, nil + }, + } + req, resp := frameworkExtensionCreateRequest(t, config) + plan := config + plan.ProjectID = types.StringValue("project_planned") + setFrameworkExtensionCreatePlan(t, &req, plan) + + createExtensionResource(context.Background(), client, "project_default", req, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if got, want := gotProjectID, "project_planned"; got != want { + t.Fatalf("project id = %q, want %q", got, want) + } +} + +func TestFrameworkCreateExtensionUsesKnownPlannedUploadMetadata(t *testing.T) { + t.Parallel() + + config := extensionFrameworkConfigForTest(t) + config.Name = types.StringUnknown() + config.SourceSHA256 = types.StringUnknown() + plan := config + plan.Name = types.StringValue("Extension") + plan.SourceSHA256 = types.StringValue("9602532cc2b8bc6ca42336f7d93a1f49b5272bf308cd4a6a132e0074efd76fbc") + client := fakeExtensionUploader{ + upload: func(ctx context.Context, projectID string, params kernel.ExtensionUploadParams) (*kernel.ExtensionUploadResponse, error) { + if !params.Name.Valid() || params.Name.Value != "Extension" { + t.Fatalf("name = %#v, want planned Extension", params.Name) + } + response := extensionUploadResponseForTest(t, `{"id":"extension_123","name":"Extension","checksum":"`+plan.SourceSHA256.ValueString()+`"}`) + return &response, nil + }, + } + req, resp := frameworkExtensionCreateRequest(t, config) + setFrameworkExtensionCreatePlan(t, &req, plan) + + createExtensionResource(context.Background(), client, "", req, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + state := frameworkExtensionCreateState(t, resp) + if !state.Name.Equal(plan.Name) { + t.Fatalf("name = %v, want %v", state.Name, plan.Name) + } + if !state.SourceSHA256.Equal(plan.SourceSHA256) { + t.Fatalf("source_sha256 = %v, want %v", state.SourceSHA256, plan.SourceSHA256) + } +} + +func TestFrameworkCreateExtensionRejectsUnknownProjectBeforeUpload(t *testing.T) { + t.Parallel() + + called := false + client := fakeExtensionUploader{ + upload: func(ctx context.Context, projectID string, params kernel.ExtensionUploadParams) (*kernel.ExtensionUploadResponse, error) { + called = true + return nil, nil + }, + } + config := extensionFrameworkConfigForTest(t) + config.ProjectID = types.StringUnknown() + req, resp := frameworkExtensionCreateRequest(t, config) + + createExtensionResource(context.Background(), client, "project_default", req, resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected unknown project diagnostic") + } + if called { + t.Fatal("UploadExtension was called with unknown project scope") + } + if !resp.State.Raw.IsNull() { + t.Fatalf("state = %v, want absent state", resp.State.Raw) + } +} + +func TestFrameworkCreateExtensionPersistsRecoverableIdentityBeforeDiagnostic(t *testing.T) { + t.Parallel() + + config := extensionFrameworkConfigForTest(t) + client := fakeExtensionUploader{ + upload: func(ctx context.Context, projectID string, params kernel.ExtensionUploadParams) (*kernel.ExtensionUploadResponse, error) { + response := extensionUploadResponseForTest(t, `{"id":"extension_123"}`) + return &response, nil + }, + } + req, resp := frameworkExtensionCreateRequest(t, config) + + createExtensionResource(context.Background(), client, "project_default", req, resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected uncertain-create diagnostic") + } + state := frameworkExtensionCreateState(t, resp) + if got, want := state.ID.ValueString(), "extension_123"; got != want { + t.Fatalf("state id = %q, want recoverable %q", got, want) + } + if !state.SourceSHA256.Equal(config.SourceSHA256) { + t.Fatalf("source_sha256 = %v, want %v", state.SourceSHA256, config.SourceSHA256) + } +} + +func TestFrameworkCreateExtensionLeavesNoStateWithoutRecoverableIdentity(t *testing.T) { + t.Parallel() + + tests := map[string]func(*testing.T) (*kernel.ExtensionUploadResponse, error){ + "definite rejection": func(t *testing.T) (*kernel.ExtensionUploadResponse, error) { + return nil, extensionAPIErrorForTest(t, http.StatusConflict, `{}`) + }, + "uncertain transport failure": func(t *testing.T) (*kernel.ExtensionUploadResponse, error) { + return nil, errors.New("response timeout") + }, + } + + for name, upload := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + client := fakeExtensionUploader{ + upload: func(ctx context.Context, projectID string, params kernel.ExtensionUploadParams) (*kernel.ExtensionUploadResponse, error) { + return upload(t) + }, + } + req, resp := frameworkExtensionCreateRequest(t, extensionFrameworkConfigForTest(t)) + + createExtensionResource(context.Background(), client, "", req, resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected create diagnostic") + } + if !resp.State.Raw.IsNull() { + t.Fatalf("state = %v, want absent state", resp.State.Raw) + } + }) + } +} + +func TestFrameworkCreateExtensionRejectsMalformedConfigBeforeUpload(t *testing.T) { + t.Parallel() + + called := false + client := fakeExtensionUploader{ + upload: func(ctx context.Context, projectID string, params kernel.ExtensionUploadParams) (*kernel.ExtensionUploadResponse, error) { + called = true + return nil, nil + }, + } + var req resource.CreateRequest + req.Config.Schema = extensionSchema() + req.Config.Raw = tftypes.NewValue(tftypes.String, "not an extension config") + resp := frameworkExtensionCreateResponse() + + createExtensionResource(context.Background(), client, "", req, resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected malformed-config diagnostic") + } + if called { + t.Fatal("UploadExtension was called for malformed configuration") + } + if !resp.State.Raw.IsNull() { + t.Fatalf("state = %v, want absent state", resp.State.Raw) + } +} + +func TestFrameworkCreateExtensionRejectsMissingClient(t *testing.T) { + t.Parallel() + + req, resp := frameworkExtensionCreateRequest(t, extensionFrameworkConfigForTest(t)) + createExtensionResource(context.Background(), nil, "", req, resp) + + if !extensionDiagnosticContains(resp.Diagnostics, "Missing Kernel Client", "provider was not configured") { + t.Fatalf("diagnostics = %v, want missing client error", resp.Diagnostics) + } + if !resp.State.Raw.IsNull() { + t.Fatalf("state = %v, want absent state", resp.State.Raw) + } +} + +func frameworkExtensionCreateRequest(t *testing.T, config extensionModel) (resource.CreateRequest, *resource.CreateResponse) { + t.Helper() + ctx := context.Background() + schema := extensionSchema() + + var encoded tfsdk.Plan + encoded.Schema = schema + if diags := encoded.Set(ctx, config); diags.HasError() { + t.Fatalf("encode extension config: %v", diags) + } + + var req resource.CreateRequest + req.Config.Schema = schema + req.Config.Raw = encoded.Raw + setFrameworkExtensionCreatePlan(t, &req, config) + return req, frameworkExtensionCreateResponse() +} + +func setFrameworkExtensionCreatePlan(t *testing.T, req *resource.CreateRequest, plan extensionModel) { + t.Helper() + plan.SourcePath = types.StringNull() + req.Plan.Schema = extensionSchema() + if diags := req.Plan.Set(context.Background(), plan); diags.HasError() { + t.Fatalf("set extension plan: %v", diags) + } +} + +func frameworkExtensionCreateResponse() *resource.CreateResponse { + resp := &resource.CreateResponse{} + resp.State.Schema = extensionSchema() + resp.State.RemoveResource(context.Background()) + return resp +} + +func frameworkExtensionCreateState(t *testing.T, resp *resource.CreateResponse) extensionModel { + t.Helper() + var state extensionModel + if diags := resp.State.Get(context.Background(), &state); diags.HasError() { + t.Fatalf("get extension state: %v", diags) + } + return state +} + +func extensionFrameworkConfigForTest(t *testing.T) extensionModel { + t.Helper() + return extensionModel{ + ID: types.StringUnknown(), + Name: types.StringNull(), + ProjectID: types.StringNull(), + SourcePath: types.StringValue(writeExtensionArchiveForTest(t, []byte("extension archive"))), + SourceSHA256: types.StringValue("9602532cc2b8bc6ca42336f7d93a1f49b5272bf308cd4a6a132e0074efd76fbc"), + } +} From a890eb5685be7083d854745dc99d22a4f6fc2d30 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:56:31 -0400 Subject: [PATCH 41/68] Flatten extension metadata for resource state --- internal/resources/extension/read_flatten.go | 58 ++++++ .../resources/extension/read_flatten_test.go | 168 ++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 internal/resources/extension/read_flatten.go create mode 100644 internal/resources/extension/read_flatten_test.go diff --git a/internal/resources/extension/read_flatten.go b/internal/resources/extension/read_flatten.go new file mode 100644 index 0000000..8c1d277 --- /dev/null +++ b/internal/resources/extension/read_flatten.go @@ -0,0 +1,58 @@ +package extension + +import ( + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" +) + +func flattenExtensionRead(response kernel.ExtensionGetResponse, prior extensionModel) (extensionModel, diag.Diagnostics) { + var diags diag.Diagnostics + + if !validExtensionResponseString(response.JSON.ID.Raw(), response.JSON.ID.Valid(), response.ID) { + addInvalidExtensionResponseField(&diags, "id") + } else if !prior.ID.IsNull() && !prior.ID.IsUnknown() && response.ID != prior.ID.ValueString() { + diags.AddError( + "Unexpected Kernel Extension ID", + fmt.Sprintf("Kernel returned extension ID %q while reading extension %q.", response.ID, prior.ID.ValueString()), + ) + } + + name := types.StringNull() + if extensionResponseFieldPresent(response.JSON.Name.Raw()) { + if !validExtensionResponseString(response.JSON.Name.Raw(), response.JSON.Name.Valid(), response.Name) { + addInvalidExtensionResponseField(&diags, "name") + } else { + name = types.StringValue(response.Name) + } + } + + checksum := types.StringNull() + if extensionResponseFieldPresent(response.JSON.Checksum.Raw()) { + if !validExtensionResponseString(response.JSON.Checksum.Raw(), response.JSON.Checksum.Valid(), response.Checksum) || + !extensionChecksumPattern.MatchString(response.Checksum) { + addInvalidExtensionResponseField(&diags, "checksum") + } else { + checksum = types.StringValue(response.Checksum) + } + } else if !prior.SourceSHA256.IsNull() && !prior.SourceSHA256.IsUnknown() { + diags.AddError( + "Missing Kernel Extension Checksum", + "Kernel no longer returned a checksum for extension "+prior.ID.ValueString()+", so Terraform cannot verify the managed archive content.", + ) + } + + if diags.HasError() { + return extensionModel{}, diags + } + + return extensionModel{ + ID: types.StringValue(response.ID), + Name: name, + ProjectID: prior.ProjectID, + SourcePath: types.StringNull(), + SourceSHA256: checksum, + }, diags +} diff --git a/internal/resources/extension/read_flatten_test.go b/internal/resources/extension/read_flatten_test.go new file mode 100644 index 0000000..33a91d8 --- /dev/null +++ b/internal/resources/extension/read_flatten_test.go @@ -0,0 +1,168 @@ +package extension + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" +) + +func TestFlattenExtensionReadMapsDurableState(t *testing.T) { + t.Parallel() + + checksum := strings.Repeat("a", 64) + state, diags := flattenExtensionRead( + extensionGetResponseForTest(t, `{ + "id":"extension_123", + "name":"Extension", + "checksum":"`+checksum+`", + "created_at":"2026-01-01T00:00:00Z", + "size_bytes":123, + "last_used_at":"2026-01-02T00:00:00Z" + }`), + extensionModel{ + ID: types.StringValue("extension_123"), + ProjectID: types.StringValue("project_123"), + SourceSHA256: types.StringValue(checksum), + }, + ) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if got, want := state.ID.ValueString(), "extension_123"; got != want { + t.Fatalf("id = %q, want %q", got, want) + } + if got, want := state.Name.ValueString(), "Extension"; got != want { + t.Fatalf("name = %q, want %q", got, want) + } + if !state.ProjectID.Equal(types.StringValue("project_123")) { + t.Fatalf("project_id = %v, want project_123", state.ProjectID) + } + if !state.SourcePath.IsNull() { + t.Fatalf("source_path = %v, want null write-only state", state.SourcePath) + } + if got, want := state.SourceSHA256.ValueString(), checksum; got != want { + t.Fatalf("source_sha256 = %q, want %q", got, want) + } +} + +func TestFlattenExtensionReadAllowsLegacyNullableMetadata(t *testing.T) { + t.Parallel() + + state, diags := flattenExtensionRead( + extensionGetResponseForTest(t, `{"id":"extension_123","name":null}`), + extensionModel{ + ID: types.StringValue("extension_123"), + ProjectID: types.StringNull(), + SourceSHA256: types.StringUnknown(), + }, + ) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if !state.Name.IsNull() { + t.Fatalf("name = %v, want null", state.Name) + } + if !state.ProjectID.IsNull() { + t.Fatalf("project_id = %v, want null", state.ProjectID) + } + if !state.SourceSHA256.IsNull() { + t.Fatalf("source_sha256 = %v, want null", state.SourceSHA256) + } +} + +func TestFlattenExtensionReadReportsLostManagedChecksum(t *testing.T) { + t.Parallel() + + _, diags := flattenExtensionRead( + extensionGetResponseForTest(t, `{"id":"extension_123","checksum":null}`), + extensionModel{ + ID: types.StringValue("extension_123"), + SourceSHA256: types.StringValue(strings.Repeat("a", 64)), + }, + ) + if !extensionDiagnosticContains(diags, "Missing Kernel Extension Checksum", "cannot verify the managed archive content") { + t.Fatalf("diagnostics = %v, want missing managed checksum", diags) + } +} + +func TestFlattenExtensionReadSurfacesRemoteChecksumDrift(t *testing.T) { + t.Parallel() + + remoteChecksum := strings.Repeat("b", 64) + state, diags := flattenExtensionRead( + extensionGetResponseForTest(t, `{"id":"extension_123","checksum":"`+remoteChecksum+`"}`), + extensionModel{ + ID: types.StringValue("extension_123"), + SourceSHA256: types.StringValue(strings.Repeat("a", 64)), + }, + ) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if got := state.SourceSHA256.ValueString(); got != remoteChecksum { + t.Fatalf("source_sha256 = %q, want remote checksum %q", got, remoteChecksum) + } +} + +func TestFlattenExtensionReadRejectsInvalidResponse(t *testing.T) { + t.Parallel() + + checksum := strings.Repeat("a", 64) + tests := map[string]struct { + body string + priorID string + summary string + detail string + }{ + "missing id": { + body: `{"checksum":"` + checksum + `"}`, + priorID: "extension_123", + summary: "Invalid Kernel Extension Response", + detail: "field id", + }, + "unexpected id": { + body: `{"id":"extension_other","checksum":"` + checksum + `"}`, + priorID: "extension_123", + summary: "Unexpected Kernel Extension ID", + detail: "extension_other", + }, + "invalid name": { + body: `{"id":"extension_123","name":123,"checksum":"` + checksum + `"}`, + priorID: "extension_123", + summary: "Invalid Kernel Extension Response", + detail: "field name", + }, + "invalid checksum": { + body: `{"id":"extension_123","checksum":"bad"}`, + priorID: "extension_123", + summary: "Invalid Kernel Extension Response", + detail: "field checksum", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, diags := flattenExtensionRead( + extensionGetResponseForTest(t, test.body), + extensionModel{ID: types.StringValue(test.priorID), SourceSHA256: types.StringNull()}, + ) + if !extensionDiagnosticContains(diags, test.summary, test.detail) { + t.Fatalf("diagnostics = %v, want %q containing %q", diags, test.summary, test.detail) + } + }) + } +} + +func extensionGetResponseForTest(t *testing.T, body string) kernel.ExtensionGetResponse { + t.Helper() + + var response kernel.ExtensionGetResponse + if err := json.Unmarshal([]byte(body), &response); err != nil { + t.Fatalf("unmarshal extension response: %v", err) + } + return response +} From 70396dff338dbc6fcb69889022783d8870f692c7 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:24:23 -0400 Subject: [PATCH 42/68] Read extension metadata safely --- internal/resources/extension/read.go | 76 +++++++++ internal/resources/extension/read_test.go | 179 ++++++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 internal/resources/extension/read.go create mode 100644 internal/resources/extension/read_test.go diff --git a/internal/resources/extension/read.go b/internal/resources/extension/read.go new file mode 100644 index 0000000..8428088 --- /dev/null +++ b/internal/resources/extension/read.go @@ -0,0 +1,76 @@ +package extension + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/terraform-provider-kernel/internal/projectscope" +) + +type extensionReader interface { + GetExtension(context.Context, string, string) (*kernel.ExtensionGetResponse, error) +} + +func readExtension(ctx context.Context, client extensionReader, state extensionModel) (extensionModel, bool, diag.Diagnostics) { + var diags diag.Diagnostics + if client == nil { + diags.AddError( + "Missing Kernel Client", + "The Kernel provider was not configured before using the extension resource.", + ) + return extensionModel{}, false, diags + } + + id, ok := extensionStateID(state, "read", &diags) + if !ok { + return extensionModel{}, false, diags + } + if state.ProjectID.IsUnknown() { + diags.AddAttributeError( + path.Root("project_id"), + "Unknown Kernel Project ID", + "Cannot read a Kernel extension while project_id is unknown in Terraform state.", + ) + return extensionModel{}, false, diags + } + + projectID := state.ProjectID.ValueString() + scope := "the API-key-bound project" + if projectID != "" { + scope = fmt.Sprintf("project %q", projectID) + } + response, err := client.GetExtension(ctx, projectID, id) + if err != nil { + if projectscope.IsNotFound(err) { + return extensionModel{}, true, diags + } + projectscope.AddError(&diags, "Read Kernel Extension", projectID, fmt.Errorf("read extension %q in %s: %w", id, scope, err)) + return extensionModel{}, false, diags + } + if response == nil { + diags.AddError( + "Read Kernel Extension", + fmt.Sprintf("Kernel returned an empty response for extension %q in %s.", id, scope), + ) + return extensionModel{}, false, diags + } + + nextState, flattenDiags := flattenExtensionRead(*response, state) + diags.Append(flattenDiags...) + return nextState, false, diags +} + +func extensionStateID(state extensionModel, operation string, diags *diag.Diagnostics) (string, bool) { + if state.ID.IsNull() || state.ID.IsUnknown() || state.ID.ValueString() == "" { + diags.AddAttributeError( + path.Root("id"), + "Missing Kernel Extension ID", + "Cannot "+operation+" a Kernel extension without a known id in Terraform state.", + ) + return "", false + } + return state.ID.ValueString(), true +} diff --git a/internal/resources/extension/read_test.go b/internal/resources/extension/read_test.go new file mode 100644 index 0000000..26f6eac --- /dev/null +++ b/internal/resources/extension/read_test.go @@ -0,0 +1,179 @@ +package extension + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/terraform-provider-kernel/internal/kernelclient" +) + +var _ extensionReader = kernelclient.Clients{} + +type fakeExtensionReader struct { + get func(context.Context, string, string) (*kernel.ExtensionGetResponse, error) +} + +func (f fakeExtensionReader) GetExtension(ctx context.Context, projectID, id string) (*kernel.ExtensionGetResponse, error) { + if f.get == nil { + return nil, errors.New("unexpected get") + } + return f.get(ctx, projectID, id) +} + +func TestReadExtensionGetsAndFlattensDurableState(t *testing.T) { + t.Parallel() + + checksum := strings.Repeat("a", 64) + prior := extensionModel{ + ID: types.StringValue("extension_123"), + ProjectID: types.StringValue("project_123"), + SourceSHA256: types.StringValue(checksum), + } + var gotProjectID, gotID string + next, removed, diags := readExtension(context.Background(), fakeExtensionReader{ + get: func(ctx context.Context, projectID, id string) (*kernel.ExtensionGetResponse, error) { + gotProjectID, gotID = projectID, id + response := extensionGetResponseForTest(t, `{"id":"extension_123","name":"Extension","checksum":"`+checksum+`"}`) + return &response, nil + }, + }, prior) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if removed { + t.Fatal("removed = true, want false") + } + if gotProjectID != "project_123" || gotID != "extension_123" { + t.Fatalf("GetExtension scope/id = %q/%q, want project_123/extension_123", gotProjectID, gotID) + } + if got, want := next.Name.ValueString(), "Extension"; got != want { + t.Fatalf("name = %q, want %q", got, want) + } + if !next.ProjectID.Equal(prior.ProjectID) { + t.Fatalf("project_id = %v, want %v", next.ProjectID, prior.ProjectID) + } +} + +func TestReadExtensionRemovesOnlyCodedNotFound(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + projectID types.String + err error + wantRemoved bool + wantSummary string + wantDetail string + }{ + "extension not found": { + err: extensionAPIErrorForTest(t, http.StatusNotFound, `{"code":"not_found"}`), + wantRemoved: true, + }, + "project not found": { + projectID: types.StringValue("project_123"), + err: extensionAPIErrorForTest(t, http.StatusNotFound, `{"code":"project_not_found"}`), + wantSummary: "Kernel Project Not Found", + wantDetail: `extension_123`, + }, + "uncoded not found": { + err: extensionAPIErrorForTest(t, http.StatusNotFound, `{}`), + wantSummary: "Read Kernel Extension", + wantDetail: "the API-key-bound project", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, removed, diags := readExtension(context.Background(), fakeExtensionReader{ + get: func(ctx context.Context, projectID, id string) (*kernel.ExtensionGetResponse, error) { + return nil, test.err + }, + }, extensionModel{ID: types.StringValue("extension_123"), ProjectID: test.projectID}) + if removed != test.wantRemoved { + t.Fatalf("removed = %t, want %t", removed, test.wantRemoved) + } + if test.wantSummary == "" { + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + } else if !extensionDiagnosticContains(diags, test.wantSummary, test.wantDetail) { + t.Fatalf("diagnostics = %v, want %q", diags, test.wantSummary) + } + }) + } +} + +func TestReadExtensionRejectsInvalidStateBeforeGet(t *testing.T) { + t.Parallel() + + tests := map[string]extensionModel{ + "missing id": {ID: types.StringNull(), ProjectID: types.StringNull()}, + "unknown id": {ID: types.StringUnknown(), ProjectID: types.StringNull()}, + "empty id": {ID: types.StringValue(""), ProjectID: types.StringNull()}, + "unknown project": {ID: types.StringValue("extension_123"), ProjectID: types.StringUnknown()}, + } + + for name, state := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + called := false + _, removed, diags := readExtension(context.Background(), fakeExtensionReader{ + get: func(ctx context.Context, projectID, id string) (*kernel.ExtensionGetResponse, error) { + called = true + return nil, nil + }, + }, state) + if !diags.HasError() { + t.Fatal("expected invalid-state diagnostic") + } + if removed { + t.Fatal("removed = true, want false") + } + if called { + t.Fatal("GetExtension called for invalid state") + } + }) + } +} + +func TestReadExtensionReportsMissingClientAndEmptyResponse(t *testing.T) { + t.Parallel() + + state := extensionModel{ID: types.StringValue("extension_123"), ProjectID: types.StringNull()} + tests := map[string]struct { + client extensionReader + summary string + detail string + }{ + "missing client": { + summary: "Missing Kernel Client", + }, + "empty response": { + client: fakeExtensionReader{ + get: func(ctx context.Context, projectID, id string) (*kernel.ExtensionGetResponse, error) { + return nil, nil + }, + }, + summary: "Read Kernel Extension", + detail: `extension_123`, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, removed, diags := readExtension(context.Background(), test.client, state) + if removed { + t.Fatal("removed = true, want false") + } + if !extensionDiagnosticContains(diags, test.summary, test.detail) { + t.Fatalf("diagnostics = %v, want %q", diags, test.summary) + } + }) + } +} From d503c89b757c72bc37dc7c3ea7e9da4e8e7462f0 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:39:40 -0400 Subject: [PATCH 43/68] Wire extension metadata into Terraform Read --- .../resources/extension/framework_read.go | 27 ++++ .../extension/framework_read_test.go | 153 ++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 internal/resources/extension/framework_read.go create mode 100644 internal/resources/extension/framework_read_test.go diff --git a/internal/resources/extension/framework_read.go b/internal/resources/extension/framework_read.go new file mode 100644 index 0000000..e4ff9d6 --- /dev/null +++ b/internal/resources/extension/framework_read.go @@ -0,0 +1,27 @@ +package extension + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func readExtensionResource(ctx context.Context, client extensionReader, req resource.ReadRequest, resp *resource.ReadResponse) { + var state extensionModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + nextState, removed, readDiags := readExtension(ctx, client, state) + resp.Diagnostics.Append(readDiags...) + if resp.Diagnostics.HasError() { + return + } + if removed { + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, nextState)...) +} diff --git a/internal/resources/extension/framework_read_test.go b/internal/resources/extension/framework_read_test.go new file mode 100644 index 0000000..4395975 --- /dev/null +++ b/internal/resources/extension/framework_read_test.go @@ -0,0 +1,153 @@ +package extension + +import ( + "context" + "net/http" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + kernel "github.com/kernel/kernel-go-sdk" +) + +func TestFrameworkReadExtensionSetsRefreshedState(t *testing.T) { + t.Parallel() + + checksum := strings.Repeat("a", 64) + prior := extensionModel{ + ID: types.StringValue("extension_123"), + Name: types.StringValue("Old"), + ProjectID: types.StringValue("project_123"), + SourcePath: types.StringNull(), + SourceSHA256: types.StringValue(checksum), + } + client := fakeExtensionReader{ + get: func(ctx context.Context, projectID, id string) (*kernel.ExtensionGetResponse, error) { + response := extensionGetResponseForTest(t, `{"id":"extension_123","name":"Current","checksum":"`+checksum+`"}`) + return &response, nil + }, + } + req, resp := frameworkExtensionReadRequest(t, prior) + + readExtensionResource(context.Background(), client, req, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + state := frameworkExtensionReadState(t, resp) + if got, want := state.Name.ValueString(), "Current"; got != want { + t.Fatalf("name = %q, want %q", got, want) + } + if !state.ProjectID.Equal(prior.ProjectID) { + t.Fatalf("project_id = %v, want %v", state.ProjectID, prior.ProjectID) + } +} + +func TestFrameworkReadExtensionRemovesCodedNotFound(t *testing.T) { + t.Parallel() + + client := fakeExtensionReader{ + get: func(ctx context.Context, projectID, id string) (*kernel.ExtensionGetResponse, error) { + return nil, extensionAPIErrorForTest(t, http.StatusNotFound, `{"code":"not_found"}`) + }, + } + req, resp := frameworkExtensionReadRequest(t, extensionReadStateForTest()) + + readExtensionResource(context.Background(), client, req, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if !resp.State.Raw.IsNull() { + t.Fatalf("state = %v, want removed resource", resp.State.Raw) + } +} + +func TestFrameworkReadExtensionPreservesStateOnDiagnostic(t *testing.T) { + t.Parallel() + + client := fakeExtensionReader{ + get: func(ctx context.Context, projectID, id string) (*kernel.ExtensionGetResponse, error) { + return nil, nil + }, + } + req, resp := frameworkExtensionReadRequest(t, extensionReadStateForTest()) + before := resp.State.Raw.Copy() + + readExtensionResource(context.Background(), client, req, resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected read diagnostic") + } + if !resp.State.Raw.Equal(before) { + t.Fatalf("state = %v, want preserved %v", resp.State.Raw, before) + } +} + +func TestFrameworkReadExtensionRejectsMalformedStateBeforeGet(t *testing.T) { + t.Parallel() + + called := false + client := fakeExtensionReader{ + get: func(ctx context.Context, projectID, id string) (*kernel.ExtensionGetResponse, error) { + called = true + return nil, nil + }, + } + var req resource.ReadRequest + req.State.Schema = extensionSchema() + req.State.Raw = tftypes.NewValue(tftypes.String, "not extension state") + resp := &resource.ReadResponse{} + resp.State.Schema = extensionSchema() + resp.State.Raw = req.State.Raw.Copy() + + readExtensionResource(context.Background(), client, req, resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected malformed-state diagnostic") + } + if called { + t.Fatal("GetExtension called for malformed state") + } + if !resp.State.Raw.Equal(req.State.Raw) { + t.Fatalf("state = %v, want preserved malformed state %v", resp.State.Raw, req.State.Raw) + } +} + +func frameworkExtensionReadRequest(t *testing.T, state extensionModel) (resource.ReadRequest, *resource.ReadResponse) { + t.Helper() + ctx := context.Background() + schema := extensionSchema() + + var req resource.ReadRequest + req.State.Schema = schema + if diags := req.State.Set(ctx, state); diags.HasError() { + t.Fatalf("set extension read state: %v", diags) + } + + resp := &resource.ReadResponse{} + resp.State.Schema = schema + resp.State.Raw = req.State.Raw.Copy() + return req, resp +} + +func frameworkExtensionReadState(t *testing.T, resp *resource.ReadResponse) extensionModel { + t.Helper() + var state extensionModel + if diags := resp.State.Get(context.Background(), &state); diags.HasError() { + t.Fatalf("get extension read state: %v", diags) + } + return state +} + +func extensionReadStateForTest() extensionModel { + return extensionModel{ + ID: types.StringValue("extension_123"), + Name: types.StringNull(), + ProjectID: types.StringNull(), + SourcePath: types.StringNull(), + SourceSHA256: types.StringUnknown(), + } +} From cb4e287b62445045651e586a1758741c23f951fa Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:57:04 -0400 Subject: [PATCH 44/68] Delete extensions without implicit cleanup --- internal/resources/extension/delete.go | 69 ++++++++++++ internal/resources/extension/delete_test.go | 112 ++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 internal/resources/extension/delete.go create mode 100644 internal/resources/extension/delete_test.go diff --git a/internal/resources/extension/delete.go b/internal/resources/extension/delete.go new file mode 100644 index 0000000..d239d3d --- /dev/null +++ b/internal/resources/extension/delete.go @@ -0,0 +1,69 @@ +package extension + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/terraform-provider-kernel/internal/projectscope" +) + +type extensionDeleter interface { + DeleteExtension(context.Context, string, string) error +} + +func deleteExtension(ctx context.Context, client extensionDeleter, state extensionModel) diag.Diagnostics { + var diags diag.Diagnostics + if client == nil { + diags.AddError("Missing Kernel Client", "The Kernel provider was not configured before using the extension resource.") + return diags + } + + id, ok := extensionStateID(state, "delete", &diags) + if !ok { + return diags + } + if state.ProjectID.IsUnknown() { + diags.AddAttributeError(path.Root("project_id"), "Unknown Kernel Project ID", "Cannot delete a Kernel extension while project_id is unknown in Terraform state.") + return diags + } + + projectID := state.ProjectID.ValueString() + if err := client.DeleteExtension(ctx, projectID, id); err != nil { + if projectscope.IsNotFound(err) { + return diags + } + if extensionDeleteInUse(err) { + diags.AddError( + "Delete Kernel Extension", + fmt.Sprintf("Kernel refused to delete extension %q because one or more browser pools reference it. Remove the extension from those durable browser pool configurations, then retry. Terraform will not mutate browser pools or running browsers implicitly.", id), + ) + return diags + } + projectscope.AddError(&diags, "Delete Kernel Extension", projectID, fmt.Errorf("delete extension %q: %w", id, err)) + } + return diags +} + +func extensionDeleteInUse(err error) bool { + var apiError *kernel.Error + if !errors.As(err, &apiError) || apiError.StatusCode != http.StatusBadRequest { + return false + } + + var body struct { + Code string `json:"code"` + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if json.Unmarshal([]byte(apiError.RawJSON()), &body) != nil { + return false + } + return body.Code == "resource_in_use" || body.Error.Code == "resource_in_use" +} diff --git a/internal/resources/extension/delete_test.go b/internal/resources/extension/delete_test.go new file mode 100644 index 0000000..2710097 --- /dev/null +++ b/internal/resources/extension/delete_test.go @@ -0,0 +1,112 @@ +package extension + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/kernel/terraform-provider-kernel/internal/kernelclient" +) + +var _ extensionDeleter = kernelclient.Clients{} + +type fakeExtensionDeleter struct { + delete func(context.Context, string, string) error +} + +func (f fakeExtensionDeleter) DeleteExtension(ctx context.Context, projectID, id string) error { + if f.delete == nil { + return errors.New("unexpected delete") + } + return f.delete(ctx, projectID, id) +} + +func TestDeleteExtensionUsesStateIdentityAndScope(t *testing.T) { + t.Parallel() + + var gotProjectID, gotID string + diags := deleteExtension(context.Background(), fakeExtensionDeleter{ + delete: func(ctx context.Context, projectID, id string) error { + gotProjectID, gotID = projectID, id + return nil + }, + }, extensionModel{ID: types.StringValue("extension_123"), ProjectID: types.StringValue("project_123")}) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if gotProjectID != "project_123" || gotID != "extension_123" { + t.Fatalf("DeleteExtension scope/id = %q/%q, want project_123/extension_123", gotProjectID, gotID) + } +} + +func TestDeleteExtensionTreatsCodedNotFoundAsSuccess(t *testing.T) { + t.Parallel() + + diags := deleteExtension(context.Background(), fakeExtensionDeleter{ + delete: func(ctx context.Context, projectID, id string) error { + return extensionAPIErrorForTest(t, http.StatusNotFound, `{"code":"not_found"}`) + }, + }, extensionModel{ID: types.StringValue("extension_123"), ProjectID: types.StringNull()}) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } +} + +func TestDeleteExtensionExplainsDurablePoolDependency(t *testing.T) { + t.Parallel() + + diags := deleteExtension(context.Background(), fakeExtensionDeleter{ + delete: func(ctx context.Context, projectID, id string) error { + return extensionAPIErrorForTest(t, http.StatusBadRequest, `{"code":"resource_in_use"}`) + }, + }, extensionModel{ID: types.StringValue("extension_123"), ProjectID: types.StringNull()}) + if !extensionDiagnosticContains(diags, "Delete Kernel Extension", "Remove the extension from those durable browser pool configurations") { + t.Fatalf("diagnostics = %v, want browser pool dependency guidance", diags) + } +} + +func TestDeleteExtensionDoesNotMisclassifyGenericErrors(t *testing.T) { + t.Parallel() + + tests := map[string]error{ + "generic bad request": extensionAPIErrorForTest(t, http.StatusBadRequest, `{}`), + "generic not found": extensionAPIErrorForTest(t, http.StatusNotFound, `{}`), + "transport failure": errors.New("connection reset"), + } + for name, err := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + diags := deleteExtension(context.Background(), fakeExtensionDeleter{ + delete: func(ctx context.Context, projectID, id string) error { return err }, + }, extensionModel{ID: types.StringValue("extension_123"), ProjectID: types.StringNull()}) + if !extensionDiagnosticContains(diags, "Delete Kernel Extension", "extension_123") { + t.Fatalf("diagnostics = %v, want generic delete error with ID", diags) + } + }) + } +} + +func TestDeleteExtensionRejectsInvalidStateBeforeCall(t *testing.T) { + t.Parallel() + + for name, state := range map[string]extensionModel{ + "missing id": {ID: types.StringNull(), ProjectID: types.StringNull()}, + "unknown project": {ID: types.StringValue("extension_123"), ProjectID: types.StringUnknown()}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + called := false + diags := deleteExtension(context.Background(), fakeExtensionDeleter{ + delete: func(ctx context.Context, projectID, id string) error { called = true; return nil }, + }, state) + if !diags.HasError() { + t.Fatal("expected invalid state diagnostic") + } + if called { + t.Fatal("DeleteExtension called for invalid state") + } + }) + } +} From 6b1210f30cb7435a158ac9e089399b991aa68363 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:17:21 -0400 Subject: [PATCH 45/68] Wire extension deletion into Terraform --- .../resources/extension/framework_delete.go | 17 +++ .../extension/framework_delete_test.go | 104 ++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 internal/resources/extension/framework_delete.go create mode 100644 internal/resources/extension/framework_delete_test.go diff --git a/internal/resources/extension/framework_delete.go b/internal/resources/extension/framework_delete.go new file mode 100644 index 0000000..7894a8f --- /dev/null +++ b/internal/resources/extension/framework_delete.go @@ -0,0 +1,17 @@ +package extension + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func deleteExtensionResource(ctx context.Context, client extensionDeleter, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state extensionModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(deleteExtension(ctx, client, state)...) +} diff --git a/internal/resources/extension/framework_delete_test.go b/internal/resources/extension/framework_delete_test.go new file mode 100644 index 0000000..96b79f6 --- /dev/null +++ b/internal/resources/extension/framework_delete_test.go @@ -0,0 +1,104 @@ +package extension + +import ( + "context" + "net/http" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestFrameworkDeleteExtensionDelegatesStateIdentityAndScope(t *testing.T) { + t.Parallel() + + var gotProjectID, gotID string + client := fakeExtensionDeleter{ + delete: func(ctx context.Context, projectID, id string) error { + gotProjectID, gotID = projectID, id + return nil + }, + } + req := frameworkExtensionDeleteRequest(t, extensionModel{ + ID: types.StringValue("extension_123"), + Name: types.StringNull(), + ProjectID: types.StringValue("project_123"), + SourcePath: types.StringNull(), + SourceSHA256: types.StringUnknown(), + }) + var resp resource.DeleteResponse + + deleteExtensionResource(context.Background(), client, req, &resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if gotProjectID != "project_123" || gotID != "extension_123" { + t.Fatalf("DeleteExtension scope/id = %q/%q, want project_123/extension_123", gotProjectID, gotID) + } +} + +func TestFrameworkDeleteExtensionReturnsDependencyDiagnostic(t *testing.T) { + t.Parallel() + + client := fakeExtensionDeleter{ + delete: func(ctx context.Context, projectID, id string) error { + return extensionAPIErrorForTest(t, http.StatusBadRequest, `{"code":"resource_in_use"}`) + }, + } + req := frameworkExtensionDeleteRequest(t, extensionDeleteStateForTest()) + var resp resource.DeleteResponse + + deleteExtensionResource(context.Background(), client, req, &resp) + + if !extensionDiagnosticContains(resp.Diagnostics, "Delete Kernel Extension", "browser pool configurations") { + t.Fatalf("diagnostics = %v, want durable dependency guidance", resp.Diagnostics) + } +} + +func TestFrameworkDeleteExtensionRejectsMalformedStateBeforeCall(t *testing.T) { + t.Parallel() + + called := false + client := fakeExtensionDeleter{ + delete: func(ctx context.Context, projectID, id string) error { + called = true + return nil + }, + } + var req resource.DeleteRequest + req.State.Schema = extensionSchema() + req.State.Raw = tftypes.NewValue(tftypes.String, "not extension state") + var resp resource.DeleteResponse + + deleteExtensionResource(context.Background(), client, req, &resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected malformed-state diagnostic") + } + if called { + t.Fatal("DeleteExtension called for malformed state") + } +} + +func frameworkExtensionDeleteRequest(t *testing.T, state extensionModel) resource.DeleteRequest { + t.Helper() + + var req resource.DeleteRequest + req.State.Schema = extensionSchema() + if diags := req.State.Set(context.Background(), state); diags.HasError() { + t.Fatalf("set extension delete state: %v", diags) + } + return req +} + +func extensionDeleteStateForTest() extensionModel { + return extensionModel{ + ID: types.StringValue("extension_123"), + Name: types.StringNull(), + ProjectID: types.StringNull(), + SourcePath: types.StringNull(), + SourceSHA256: types.StringUnknown(), + } +} From 345affd13514e845c0b8d579b6da441194af1d5f Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:38:48 -0400 Subject: [PATCH 46/68] Import extensions with stable project scope --- internal/resources/extension/import.go | 60 +++++++++ internal/resources/extension/import_test.go | 140 ++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 internal/resources/extension/import.go create mode 100644 internal/resources/extension/import_test.go diff --git a/internal/resources/extension/import.go b/internal/resources/extension/import.go new file mode 100644 index 0000000..ad8f6f3 --- /dev/null +++ b/internal/resources/extension/import.go @@ -0,0 +1,60 @@ +package extension + +import ( + "context" + "strconv" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/kernel/terraform-provider-kernel/internal/projectscope" +) + +type extensionImporter interface { + DefaultProjectID() string +} + +func importExtensionResource(ctx context.Context, client extensionImporter, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + if client == nil { + resp.Diagnostics.AddError( + "Missing Kernel Client", + "The Kernel provider was not configured before importing an extension resource.", + ) + return + } + + projectID, extensionID, ok := parseExtensionImportID(req.ID) + if !ok { + resp.Diagnostics.AddError( + "Invalid Kernel Extension Import ID", + "Cannot import "+strconv.Quote(req.ID)+": import an extension as \"\" or \"/\". "+ + "The bare form uses the provider project_id when configured, otherwise the API key's project binding. Use the qualified form for a different project.", + ) + return + } + if projectID == "" { + projectID = client.DefaultProjectID() + } + + resp.Diagnostics.Append(resp.State.Set(ctx, extensionModel{ + ID: types.StringValue(extensionID), + Name: types.StringUnknown(), + ProjectID: projectscope.StateValue(projectID), + SourcePath: types.StringNull(), + SourceSHA256: types.StringUnknown(), + })...) +} + +func parseExtensionImportID(id string) (projectID, extensionID string, ok bool) { + before, after, found := strings.Cut(id, "/") + if !found { + return "", id, id != "" + } + if strings.Contains(after, "/") { + return "", "", false + } + if before == "" || after == "" { + return "", "", false + } + return before, after, true +} diff --git a/internal/resources/extension/import_test.go b/internal/resources/extension/import_test.go new file mode 100644 index 0000000..f68bc1e --- /dev/null +++ b/internal/resources/extension/import_test.go @@ -0,0 +1,140 @@ +package extension + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/kernel/terraform-provider-kernel/internal/kernelclient" +) + +var _ extensionImporter = kernelclient.Clients{} + +type fakeExtensionImporter struct { + defaultProjectID string +} + +func (f fakeExtensionImporter) DefaultProjectID() string { + return f.defaultProjectID +} + +func TestParseExtensionImportID(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + input string + projectID string + extensionID string + ok bool + }{ + "bare id": {input: "extension_123", extensionID: "extension_123", ok: true}, + "qualified id": {input: "project_123/extension_123", projectID: "project_123", extensionID: "extension_123", ok: true}, + "empty": {input: ""}, + "empty project": {input: "/extension_123"}, + "empty extension": {input: "project_123/"}, + "extra separator": {input: "project_123/extension_123/extra"}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + projectID, extensionID, ok := parseExtensionImportID(test.input) + if projectID != test.projectID || extensionID != test.extensionID || ok != test.ok { + t.Fatalf("parseExtensionImportID(%q) = %q, %q, %t; want %q, %q, %t", test.input, projectID, extensionID, ok, test.projectID, test.extensionID, test.ok) + } + }) + } +} + +func TestFrameworkImportExtensionSetsRecoverableState(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + importID string + defaultProjectID string + wantProjectID types.String + }{ + "bare id inherits provider default": { + importID: "extension_123", + defaultProjectID: "project_default", + wantProjectID: types.StringValue("project_default"), + }, + "bare id remains api key scoped": { + importID: "extension_123", + wantProjectID: types.StringNull(), + }, + "qualified id overrides provider default": { + importID: "project_explicit/extension_123", + defaultProjectID: "project_default", + wantProjectID: types.StringValue("project_explicit"), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + resp := frameworkExtensionImportResponse() + importExtensionResource(context.Background(), fakeExtensionImporter{defaultProjectID: test.defaultProjectID}, resource.ImportStateRequest{ID: test.importID}, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + state := frameworkExtensionImportState(t, resp) + if got, want := state.ID.ValueString(), "extension_123"; got != want { + t.Fatalf("id = %q, want %q", got, want) + } + if !state.ProjectID.Equal(test.wantProjectID) { + t.Fatalf("project_id = %v, want %v", state.ProjectID, test.wantProjectID) + } + if !state.Name.IsUnknown() || !state.SourceSHA256.IsUnknown() { + t.Fatalf("name/source_sha256 = %v/%v, want unknown until Read", state.Name, state.SourceSHA256) + } + if !state.SourcePath.IsNull() { + t.Fatalf("source_path = %v, want null write-only state", state.SourcePath) + } + }) + } +} + +func TestFrameworkImportExtensionRejectsInvalidIDAndMissingClient(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + client extensionImporter + importID string + summary string + }{ + "missing client": {importID: "extension_123", summary: "Missing Kernel Client"}, + "invalid id": {client: fakeExtensionImporter{}, importID: "project/extension/extra", summary: "Invalid Kernel Extension Import ID"}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + resp := frameworkExtensionImportResponse() + importExtensionResource(context.Background(), test.client, resource.ImportStateRequest{ID: test.importID}, resp) + if !extensionDiagnosticContains(resp.Diagnostics, test.summary, "") { + t.Fatalf("diagnostics = %v, want %q", resp.Diagnostics, test.summary) + } + if !resp.State.Raw.IsNull() { + t.Fatalf("state = %v, want absent state", resp.State.Raw) + } + }) + } +} + +func frameworkExtensionImportResponse() *resource.ImportStateResponse { + resp := &resource.ImportStateResponse{} + resp.State.Schema = extensionSchema() + resp.State.RemoveResource(context.Background()) + return resp +} + +func frameworkExtensionImportState(t *testing.T, resp *resource.ImportStateResponse) extensionModel { + t.Helper() + var state extensionModel + if diags := resp.State.Get(context.Background(), &state); diags.HasError() { + t.Fatalf("get extension import state: %v", diags) + } + return state +} From 073a4b884105e8dab5ff822f626f1914e52827e3 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 04:19:25 -0400 Subject: [PATCH 47/68] Register the durable extension resource Assemble the reviewed extension lifecycle helpers behind a Terraform Plugin Framework resource, require local archive inputs only for create or replacement, and publish generated schema documentation. This makes the durable extension surface usable while keeping runtime operations out of Terraform. --- docs/resources/extension.md | 29 +++ internal/provider/provider.go | 6 +- internal/provider/provider_test.go | 6 +- .../extension/framework_resource_test.go | 82 +++++++ internal/resources/extension/modify_plan.go | 61 +++++ .../resources/extension/modify_plan_test.go | 217 ++++++++++++++++++ internal/resources/extension/resource.go | 85 +++++++ 7 files changed, 481 insertions(+), 5 deletions(-) create mode 100644 docs/resources/extension.md create mode 100644 internal/resources/extension/framework_resource_test.go create mode 100644 internal/resources/extension/modify_plan.go create mode 100644 internal/resources/extension/modify_plan_test.go create mode 100644 internal/resources/extension/resource.go diff --git a/docs/resources/extension.md b/docs/resources/extension.md new file mode 100644 index 0000000..a5711c6 --- /dev/null +++ b/docs/resources/extension.md @@ -0,0 +1,29 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "kernel_extension Resource - Kernel" +subcategory: "" +description: |- + Kernel uploaded extension durable configuration. +--- + +# kernel_extension (Resource) + +Kernel uploaded extension durable configuration. + + + + +## Schema + +### Optional + +> **NOTE**: [Write-only arguments](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments) are supported in Terraform 1.11 and later. + +- `name` (String) Optional extension name. Must be unique within the project. Adding or changing a configured name replaces the extension; omitting it preserves the remote name because the API cannot clear a name. +- `project_id` (String) Project this extension belongs to. Defaults to the provider `project_id` when unset; when neither is set, the API key's project binding determines the project. Adding or changing it replaces the extension. +- `source_path` (String, [Write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments)) Local path to the extension ZIP. Required when creating or replacing the extension and never stored in Terraform plan or state artifacts. Requires Terraform 1.11 or later. +- `source_sha256` (String) Lowercase hexadecimal SHA-256 checksum of the exact extension ZIP bytes. Configure with `filesha256(source_path)`. Adding or changing it replaces the extension. + +### Read-Only + +- `id` (String) Unique extension identifier. diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 5e3a9ee..b9ed3dc 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -7,12 +7,13 @@ import ( "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/kernel/terraform-provider-kernel/internal/datasources/extension" + extensiondatasource "github.com/kernel/terraform-provider-kernel/internal/datasources/extension" "github.com/kernel/terraform-provider-kernel/internal/datasources/profile" projectdatasource "github.com/kernel/terraform-provider-kernel/internal/datasources/project" "github.com/kernel/terraform-provider-kernel/internal/datasources/proxy" "github.com/kernel/terraform-provider-kernel/internal/kernelclient" "github.com/kernel/terraform-provider-kernel/internal/resources/browserpool" + extensionresource "github.com/kernel/terraform-provider-kernel/internal/resources/extension" projectresource "github.com/kernel/terraform-provider-kernel/internal/resources/project" ) @@ -82,6 +83,7 @@ func (p *kernelProvider) Configure(ctx context.Context, req provider.ConfigureRe func (p *kernelProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ browserpool.NewResource, + extensionresource.NewResource, projectresource.NewResource, } } @@ -91,6 +93,6 @@ func (p *kernelProvider) DataSources(ctx context.Context) []func() datasource.Da projectdatasource.NewDataSource, profile.NewDataSource, proxy.NewDataSource, - extension.NewDataSource, + extensiondatasource.NewDataSource, } } diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index a3fdd0d..12d5a4d 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_extension", "kernel_project"} { if !got[want] { t.Fatalf("missing resource %s; got %v", want, got) } diff --git a/internal/resources/extension/framework_resource_test.go b/internal/resources/extension/framework_resource_test.go new file mode 100644 index 0000000..ae27e8a --- /dev/null +++ b/internal/resources/extension/framework_resource_test.go @@ -0,0 +1,82 @@ +package extension + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/kernel/terraform-provider-kernel/internal/kernelclient" +) + +var _ extensionClient = kernelclient.Clients{} + +func TestExtensionResourceMetadataAndSchema(t *testing.T) { + t.Parallel() + + r := NewResource() + var metadata resource.MetadataResponse + r.Metadata(context.Background(), resource.MetadataRequest{ProviderTypeName: "kernel"}, &metadata) + if metadata.TypeName != "kernel_extension" { + t.Fatalf("type name = %q, want kernel_extension", metadata.TypeName) + } + + var schema resource.SchemaResponse + r.Schema(context.Background(), resource.SchemaRequest{}, &schema) + for _, name := range []string{"id", "name", "project_id", "source_path", "source_sha256"} { + if _, ok := schema.Schema.Attributes[name]; !ok { + t.Fatalf("extension schema missing %s attribute", name) + } + } +} + +func TestExtensionResourceConfigure(t *testing.T) { + t.Parallel() + + t.Run("durable client", func(t *testing.T) { + t.Parallel() + r := &extensionResource{} + var resp resource.ConfigureResponse + r.Configure(context.Background(), resource.ConfigureRequest{ProviderData: kernelclient.Clients{}}, &resp) + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if r.client == nil { + t.Fatal("extension client was not configured") + } + }) + + t.Run("unexpected provider data", func(t *testing.T) { + t.Parallel() + r := &extensionResource{} + var resp resource.ConfigureResponse + r.Configure(context.Background(), resource.ConfigureRequest{ProviderData: "not a client"}, &resp) + if len(resp.Diagnostics) != 1 || resp.Diagnostics[0].Summary() != "Unexpected Kernel Client Type" { + t.Fatalf("diagnostics = %v, want Unexpected Kernel Client Type", resp.Diagnostics) + } + }) + + t.Run("nil provider data", func(t *testing.T) { + t.Parallel() + r := &extensionResource{} + var resp resource.ConfigureResponse + r.Configure(context.Background(), resource.ConfigureRequest{}, &resp) + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if r.client != nil { + t.Fatal("nil provider data configured a client") + } + }) +} + +func TestExtensionResourceRejectsUpdate(t *testing.T) { + t.Parallel() + + r := &extensionResource{} + var resp resource.UpdateResponse + r.Update(context.Background(), resource.UpdateRequest{}, &resp) + + if len(resp.Diagnostics) != 1 || resp.Diagnostics[0].Summary() != "Unexpected Kernel Extension Update" { + t.Fatalf("diagnostics = %v, want Unexpected Kernel Extension Update", resp.Diagnostics) + } +} diff --git a/internal/resources/extension/modify_plan.go b/internal/resources/extension/modify_plan.go new file mode 100644 index 0000000..b0290f1 --- /dev/null +++ b/internal/resources/extension/modify_plan.go @@ -0,0 +1,61 @@ +package extension + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func modifyExtensionPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + if req.Plan.Raw.IsNull() { + return + } + + var config extensionModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + var plan extensionModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + requiresArchive := req.State.Raw.IsNull() + if !requiresArchive { + var state extensionModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + requiresArchive = extensionReplacementPlanned(state, plan) + } + if !requiresArchive { + return + } + + if config.SourcePath.IsNull() { + resp.Diagnostics.AddAttributeError( + path.Root("source_path"), + "Missing Extension Source Path", + "source_path must be configured when creating or replacing a Kernel extension.", + ) + } + if config.SourceSHA256.IsNull() { + resp.Diagnostics.AddAttributeError( + path.Root("source_sha256"), + "Missing Extension Source Checksum", + "source_sha256 must be configured when creating or replacing a Kernel extension. Use filesha256(source_path) to track the exact archive bytes.", + ) + } +} + +func extensionReplacementPlanned(state, plan extensionModel) bool { + return extensionStringChanged(state.Name, plan.Name) || + extensionStringChanged(state.ProjectID, plan.ProjectID) || + extensionStringChanged(state.SourceSHA256, plan.SourceSHA256) +} + +func extensionStringChanged(state, plan types.String) bool { + return !state.Equal(plan) +} diff --git a/internal/resources/extension/modify_plan_test.go b/internal/resources/extension/modify_plan_test.go new file mode 100644 index 0000000..c8f0994 --- /dev/null +++ b/internal/resources/extension/modify_plan_test.go @@ -0,0 +1,217 @@ +package extension + +import ( + "context" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestModifyExtensionPlanRequiresArchiveForCreate(t *testing.T) { + t.Parallel() + + config := extensionPlanModel() + config.SourcePath = types.StringNull() + config.SourceSHA256 = types.StringNull() + req := extensionModifyPlanRequest(t, config, extensionModel{}, config, false, true) + var resp resource.ModifyPlanResponse + + modifyExtensionPlan(context.Background(), req, &resp) + + assertExtensionPlanDiagnosticPath(t, resp.Diagnostics, path.Root("source_path")) + assertExtensionPlanDiagnosticPath(t, resp.Diagnostics, path.Root("source_sha256")) +} + +func TestModifyExtensionPlanRequiresArchiveForImmutableReplacement(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + change func(*extensionModel) + omitChecksum bool + }{ + "name": { + change: func(plan *extensionModel) { plan.Name = types.StringValue("New") }, + omitChecksum: true, + }, + "project_id": { + change: func(plan *extensionModel) { plan.ProjectID = types.StringValue("project_new") }, + omitChecksum: true, + }, + "source_sha256": { + change: func(plan *extensionModel) { plan.SourceSHA256 = types.StringValue(extensionChecksum("b")) }, + }, + } + + for name, change := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + state := extensionPlanModel() + plan := state + change.change(&plan) + config := plan + config.SourcePath = types.StringNull() + if change.omitChecksum { + config.SourceSHA256 = types.StringNull() + } + req := extensionModifyPlanRequest(t, config, state, plan, true, true) + var resp resource.ModifyPlanResponse + + modifyExtensionPlan(context.Background(), req, &resp) + + assertExtensionPlanDiagnosticPath(t, resp.Diagnostics, path.Root("source_path")) + if change.omitChecksum { + assertExtensionPlanDiagnosticPath(t, resp.Diagnostics, path.Root("source_sha256")) + } + }) + } +} + +func TestModifyExtensionPlanAllowsCompleteCreateOrReplacement(t *testing.T) { + t.Parallel() + + state := extensionPlanModel() + plan := state + plan.Name = types.StringValue("New") + config := plan + config.SourcePath = types.StringValue("extension.zip") + req := extensionModifyPlanRequest(t, config, state, plan, true, true) + var resp resource.ModifyPlanResponse + + modifyExtensionPlan(context.Background(), req, &resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } +} + +func TestModifyExtensionPlanDoesNotRequireArchiveForStableStateOrDestroy(t *testing.T) { + t.Parallel() + + state := extensionPlanModel() + config := state + config.SourcePath = types.StringNull() + config.SourceSHA256 = types.StringNull() + + t.Run("stable state", func(t *testing.T) { + t.Parallel() + req := extensionModifyPlanRequest(t, config, state, state, true, true) + var resp resource.ModifyPlanResponse + modifyExtensionPlan(context.Background(), req, &resp) + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + }) + + t.Run("destroy", func(t *testing.T) { + t.Parallel() + req := extensionModifyPlanRequest(t, config, state, extensionModel{}, true, false) + var resp resource.ModifyPlanResponse + modifyExtensionPlan(context.Background(), req, &resp) + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + }) +} + +func TestModifyExtensionPlanDefersUnknownArchiveInputs(t *testing.T) { + t.Parallel() + + config := extensionPlanModel() + config.SourcePath = types.StringUnknown() + config.SourceSHA256 = types.StringUnknown() + req := extensionModifyPlanRequest(t, config, extensionModel{}, config, false, true) + var resp resource.ModifyPlanResponse + + modifyExtensionPlan(context.Background(), req, &resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics for deferred inputs: %v", resp.Diagnostics) + } +} + +func TestModifyExtensionPlanRequiresArchiveForUnknownReplacementValue(t *testing.T) { + t.Parallel() + + state := extensionPlanModel() + plan := state + plan.Name = types.StringUnknown() + config := plan + config.SourcePath = types.StringNull() + config.SourceSHA256 = types.StringNull() + req := extensionModifyPlanRequest(t, config, state, plan, true, true) + var resp resource.ModifyPlanResponse + + modifyExtensionPlan(context.Background(), req, &resp) + + assertExtensionPlanDiagnosticPath(t, resp.Diagnostics, path.Root("source_path")) + assertExtensionPlanDiagnosticPath(t, resp.Diagnostics, path.Root("source_sha256")) +} + +func extensionModifyPlanRequest(t *testing.T, config, state, plan extensionModel, hasState, hasPlan bool) resource.ModifyPlanRequest { + t.Helper() + ctx := context.Background() + schema := extensionSchema() + + configValue := encodeExtensionPlanModel(t, config) + req := resource.ModifyPlanRequest{ + Config: tfsdk.Config{Schema: schema, Raw: configValue}, + State: tfsdk.State{Schema: schema}, + Plan: tfsdk.Plan{Schema: schema}, + } + if hasState { + if diags := req.State.Set(ctx, state); diags.HasError() { + t.Fatalf("set extension state: %v", diags) + } + } else { + req.State.RemoveResource(ctx) + } + if hasPlan { + if diags := req.Plan.Set(ctx, plan); diags.HasError() { + t.Fatalf("set extension plan: %v", diags) + } + } else { + req.Plan.Raw = tftypes.NewValue(schema.Type().TerraformType(ctx), nil) + } + return req +} + +func encodeExtensionPlanModel(t *testing.T, model extensionModel) tftypes.Value { + t.Helper() + var encoded tfsdk.Plan + encoded.Schema = extensionSchema() + if diags := encoded.Set(context.Background(), model); diags.HasError() { + t.Fatalf("encode extension model: %v", diags) + } + return encoded.Raw +} + +func extensionPlanModel() extensionModel { + return extensionModel{ + ID: types.StringValue("extension_123"), + Name: types.StringValue("Extension"), + ProjectID: types.StringValue("project_123"), + SourcePath: types.StringNull(), + SourceSHA256: types.StringValue(extensionChecksum("a")), + } +} + +func extensionChecksum(character string) string { + return strings.Repeat(character, 64) +} + +func assertExtensionPlanDiagnosticPath(t *testing.T, diags diag.Diagnostics, want path.Path) { + t.Helper() + for _, diagnostic := range diags { + withPath, ok := diagnostic.(diag.DiagnosticWithPath) + if ok && withPath.Path().Equal(want) { + return + } + } + t.Fatalf("diagnostics = %v, want path %s", diags, want) +} diff --git a/internal/resources/extension/resource.go b/internal/resources/extension/resource.go new file mode 100644 index 0000000..1de7c7e --- /dev/null +++ b/internal/resources/extension/resource.go @@ -0,0 +1,85 @@ +package extension + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +var ( + _ resource.Resource = (*extensionResource)(nil) + _ resource.ResourceWithConfigure = (*extensionResource)(nil) + _ resource.ResourceWithImportState = (*extensionResource)(nil) + _ resource.ResourceWithModifyPlan = (*extensionResource)(nil) +) + +type extensionClient interface { + extensionUploader + extensionReader + extensionDeleter + extensionImporter +} + +type extensionResource struct { + client extensionClient +} + +func NewResource() resource.Resource { + return &extensionResource{} +} + +func (r *extensionResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_extension" +} + +func (r *extensionResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = extensionSchema() +} + +func (r *extensionResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(extensionClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Kernel Client Type", + "Expected provider data to implement the extension durable client contract.", + ) + return + } + + r.client = client +} + +func (r *extensionResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + defaultProjectID := "" + if r.client != nil { + defaultProjectID = r.client.DefaultProjectID() + } + createExtensionResource(ctx, r.client, defaultProjectID, req, resp) +} + +func (r *extensionResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + readExtensionResource(ctx, r.client, req, resp) +} + +func (r *extensionResource) Update(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.AddError( + "Unexpected Kernel Extension Update", + "Kernel extensions are immutable. Terraform should replace the extension when durable configuration changes; reaching Update indicates a provider planning error.", + ) +} + +func (r *extensionResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + deleteExtensionResource(ctx, r.client, req, resp) +} + +func (r *extensionResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + importExtensionResource(ctx, r.client, req, resp) +} + +func (r *extensionResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + modifyExtensionPlan(ctx, req, resp) +} From 88438e4ec8a9cfc5ea4b53a79ade51fa0413f7b4 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 04:50:11 -0400 Subject: [PATCH 48/68] Test the extension lifecycle against Kernel Add an opt-in Terraform acceptance test that uploads a valid extension archive, verifies stable plans and import, replaces content by checksum, confirms the old object is deleted, and checks final cleanup. Extend the manual acceptance matrix and release guidance so the durable extension resource has an explicit real-API release gate. --- .github/workflows/acceptance.yml | 5 +- README.md | 14 +- docs/release.md | 10 +- internal/acctest/acctest.go | 42 ++++ internal/acctest/acctest_test.go | 112 +++++++++ .../resources/extension/resource_acc_test.go | 219 ++++++++++++++++++ 6 files changed, 393 insertions(+), 9 deletions(-) create mode 100644 internal/resources/extension/resource_acc_test.go diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 41ba489..8cf7b0b 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -24,6 +24,9 @@ jobs: - name: Project package: ./internal/resources/project project_id_required: false + - name: Extension + package: ./internal/resources/extension + project_id_required: true # Keep the job timeout above the go test timeout so Go can report the test # timeout before the runner stops the job. Either hard timeout can bypass # t.Cleanup, so release operators still inspect for leaked test resources. @@ -44,7 +47,7 @@ jobs: env: KERNEL_PROJECT_ID: ${{ secrets.KERNEL_PROJECT_ID }} run: | - : "${KERNEL_PROJECT_ID:?Set the KERNEL_PROJECT_ID repository secret before running browser-pool acceptance tests.}" + : "${KERNEL_PROJECT_ID:?Set the KERNEL_PROJECT_ID repository secret before running project-scoped acceptance tests.}" - name: Checkout uses: actions/checkout@v6 diff --git a/README.md b/README.md index 39aecf4..0445ea6 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Provider configuration: Resources: - `kernel_browser_pool` +- `kernel_extension` - `kernel_project` Data sources: @@ -27,6 +28,7 @@ Data sources: Import: - `kernel_browser_pool` by canonical browser pool ID +- `kernel_extension` by canonical extension ID, optionally qualified with its project ID - `kernel_project` by canonical project ID ## Not Supported @@ -39,8 +41,8 @@ The provider intentionally does not manage: - logs, screenshots, or live view - runtime status or standby state - force-release or recovery operations -- API key, profile, proxy, or extension resources -- extension upload, download, delete, or Chrome Store operations +- API key, profile, proxy, or deployment resources +- extension download or Chrome Web Store download operations ## Quickstart @@ -83,6 +85,8 @@ use the project-qualified form to import from a different project: ```sh terraform import kernel_browser_pool.example terraform import kernel_browser_pool.example / +terraform import kernel_extension.example +terraform import kernel_extension.example / terraform import kernel_project.example ``` @@ -151,12 +155,14 @@ Run the durable resource acceptance tests: ```sh go test -count=1 -timeout=30m -v ./internal/resources/browserpool -run TestAcc +go test -count=1 -timeout=30m -v ./internal/resources/extension -run TestAcc go test -count=1 -timeout=30m -v ./internal/resources/project -run TestAcc ``` The tests create uniquely named durable resources and register independent -cleanup. Browser-pool deletion remains `force=false`. The tests do not acquire -browsers or perform runtime recovery. +cleanup. Extension acceptance creates a small temporary Manifest V3 archive and +tests checksum-driven replacement. Browser-pool deletion remains `force=false`. +The tests do not acquire browsers or perform runtime recovery. ## Architecture diff --git a/docs/release.md b/docs/release.md index 7a4508d..276aff3 100644 --- a/docs/release.md +++ b/docs/release.md @@ -14,13 +14,15 @@ Use this checklist before publishing a Kernel Terraform provider version. - Run `go vet ./...`. - Run the complete opt-in acceptance matrix for every v1 resource and data source with real credentials before the first public release. - Browser pools: `TF_ACC=1 KERNEL_ACC=1 KERNEL_API_KEY=... KERNEL_PROJECT_ID=... go test -count=1 -timeout=30m -v ./internal/resources/browserpool -run TestAcc`. + - Extensions: `TF_ACC=1 KERNEL_ACC=1 KERNEL_API_KEY=... KERNEL_PROJECT_ID=... go test -count=1 -timeout=30m -v ./internal/resources/extension -run TestAcc`. - Projects: `TF_ACC=1 KERNEL_ACC=1 KERNEL_API_KEY=... go test -count=1 -timeout=30m -v ./internal/resources/project -run TestAcc`. - - The manual `Acceptance` workflow runs both packages in parallel; expand its matrix as v1 resources land and keep live tests out of normal PR CI. + - The manual `Acceptance` workflow runs all three packages in parallel; expand its matrix as v1 resources land and keep live tests out of normal PR 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 and browser pools named `kernel-tf-*` that were created during the failed workflow run. + 1. In the Kernel dashboard or durable API, find projects, browser pools, and extensions named `kernel-tf-*` that were created during the failed workflow run. 2. Delete leaked browser pools first 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 a leaked project only after its child resources are gone and the organization still has another active project. - 4. Read each canonical resource ID and require a 404 before considering cleanup complete. + 3. Delete leaked extensions after removing any durable browser-pool references to them. Do not mutate pools or running browsers implicitly. + 4. Delete a leaked project only after its child resources are gone and the organization still has another active project. + 5. Read each canonical resource ID and require a 404 before considering cleanup complete. - Verify unscoped API calls send no `X-Kernel-Project-Id` header; it is sent only when a resource-level `project_id` or the provider default resolves a project. - Confirm `terraform-registry-manifest.json` contains protocol `["6.0"]` for Terraform Plugin Framework. - Confirm the repository license before the first public release. Do not publish a public tag until `LICENSE` exists or the release owner has explicitly documented the licensing decision. diff --git a/internal/acctest/acctest.go b/internal/acctest/acctest.go index 03b92c2..1571511 100644 --- a/internal/acctest/acctest.go +++ b/internal/acctest/acctest.go @@ -159,6 +159,48 @@ func cleanupProject(t testing.TB, client projectCleaner, id string) { }) } +// CleanupExtension registers a cleanup that deletes the extension from +// projectID; empty means the env-configured default project. +func CleanupExtension(t testing.TB, projectID, id string) { + t.Helper() + + cleanupExtension(t, ClientFromEnv(), projectID, id) +} + +type extensionCleaner interface { + DefaultProjectID() string + DeleteExtension(context.Context, string, string) error +} + +func cleanupExtension(t testing.TB, client extensionCleaner, projectID, 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 + } + + if projectID == "" { + projectID = client.DefaultProjectID() + } + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) + defer cancel() + + if err := client.DeleteExtension(ctx, projectID, id); err != nil && !IsNotFound(err) { + t.Errorf("cleanup Kernel extension %s: %v", id, err) + } + }) +} + func ClientFromEnv() kernelclient.Clients { return kernelclient.New(kernelclient.Config{ APIKey: os.Getenv(EnvAPIKey), diff --git a/internal/acctest/acctest_test.go b/internal/acctest/acctest_test.go index 2b246ae..226b0c3 100644 --- a/internal/acctest/acctest_test.go +++ b/internal/acctest/acctest_test.go @@ -21,6 +21,19 @@ type fakeProjectCleaner struct { delete func(context.Context, string) error } +type fakeExtensionCleaner struct { + defaultProjectID string + delete func(context.Context, string, string) error +} + +func (f fakeExtensionCleaner) DefaultProjectID() string { + return f.defaultProjectID +} + +func (f fakeExtensionCleaner) DeleteExtension(ctx context.Context, projectID, id string) error { + return f.delete(ctx, projectID, id) +} + func (f fakeProjectCleaner) DeleteProject(ctx context.Context, id string) error { return f.delete(ctx, id) } @@ -343,6 +356,105 @@ func TestCleanupProject(t *testing.T) { } } +func TestCleanupExtension(t *testing.T) { + tests := map[string]struct { + acceptance string + apiKey string + projectID string + defaultProject string + id string + deleteErr error + wantProjectID string + wantCleanups int + wantDelete bool + wantFailure bool + }{ + "empty ID is ignored": {}, + "acceptance disabled": { + apiKey: "test-key", + id: "extension_123", + wantFailure: true, + }, + "API key missing": { + acceptance: "1", + id: "extension_123", + wantFailure: true, + }, + "default project is resolved": { + acceptance: "1", + apiKey: "test-key", + defaultProject: "project_default", + id: "extension_123", + wantProjectID: "project_default", + wantCleanups: 1, + wantDelete: true, + }, + "explicit project wins": { + acceptance: "1", + apiKey: "test-key", + projectID: "project_explicit", + defaultProject: "project_default", + id: "extension_123", + deleteErr: notFoundAPIError(), + wantProjectID: "project_explicit", + wantCleanups: 1, + wantDelete: true, + }, + "delete error is reported": { + acceptance: "1", + apiKey: "test-key", + projectID: "project_explicit", + id: "extension_123", + deleteErr: errors.New("connection reset"), + wantProjectID: "project_explicit", + wantCleanups: 1, + wantDelete: true, + wantFailure: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Setenv(EnvAcceptance, test.acceptance) + t.Setenv(EnvAPIKey, test.apiKey) + + var gotID, gotProjectID string + deleteCalled := false + deadlineSet := false + recorder := &testRecorder{TB: t} + cleanupExtension(recorder, fakeExtensionCleaner{ + defaultProjectID: test.defaultProject, + delete: func(ctx context.Context, projectID, id string) error { + deleteCalled = true + _, deadlineSet = ctx.Deadline() + gotProjectID = projectID + gotID = id + return test.deleteErr + }, + }, test.projectID, test.id) + + if got, want := len(recorder.cleanups), test.wantCleanups; got != want { + t.Fatalf("cleanupExtension registered %d cleanups, want %d", got, want) + } + if test.wantCleanups == 1 { + recorder.cleanups[0]() + } + if recorder.failed != test.wantFailure { + t.Fatalf("cleanupExtension failure = %t, want %t", recorder.failed, test.wantFailure) + } + if deleteCalled != test.wantDelete { + t.Fatalf("cleanupExtension called delete = %t, want %t", deleteCalled, test.wantDelete) + } + if deleteCalled && !deadlineSet { + t.Fatal("cleanupExtension called delete without a context deadline") + } + if test.wantDelete && (gotID != test.id || gotProjectID != test.wantProjectID) { + t.Fatalf("cleanup extension scope/id = %q/%q, want %q/%q", gotProjectID, gotID, test.wantProjectID, test.id) + } + }) + } +} + func TestUniqueNameIsKernelScopedAndSafe(t *testing.T) { t.Parallel() diff --git a/internal/resources/extension/resource_acc_test.go b/internal/resources/extension/resource_acc_test.go new file mode 100644 index 0000000..9ccbfa0 --- /dev/null +++ b/internal/resources/extension/resource_acc_test.go @@ -0,0 +1,219 @@ +package extension_test + +import ( + "archive/zip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "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 extensionResourceName = "kernel_extension.test" + +func TestAccExtensionLifecycle(t *testing.T) { + name := acctest.UniqueName(t, "extension") + firstPath, firstChecksum := testAccWriteExtensionArchive(t, "first") + secondPath, secondChecksum := testAccWriteExtensionArchive(t, "second") + firstConfig := testAccExtensionConfig(name, firstPath) + secondConfig := testAccExtensionConfig(name, secondPath) + var firstID, secondID string + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + if os.Getenv(acctest.EnvProjectID) == "" { + t.Fatalf("%s must be set for extension acceptance tests", acctest.EnvProjectID) + } + }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories(), + CheckDestroy: testAccCheckExtensionDestroyed(), + Steps: []resource.TestStep{ + { + Config: firstConfig, + Check: resource.ComposeAggregateTestCheckFunc( + testAccCaptureExtensionID(t, extensionResourceName, &firstID), + resource.TestCheckResourceAttrSet(extensionResourceName, "id"), + resource.TestCheckResourceAttr(extensionResourceName, "name", name), + resource.TestCheckResourceAttr(extensionResourceName, "project_id", os.Getenv(acctest.EnvProjectID)), + resource.TestCheckResourceAttr(extensionResourceName, "source_sha256", firstChecksum), + ), + }, + { + Config: firstConfig, + PlanOnly: true, + }, + { + ResourceName: extensionResourceName, + ImportState: true, + ImportStateVerify: true, + ImportStatePersist: true, + }, + { + Config: firstConfig, + PlanOnly: true, + }, + { + Config: secondConfig, + Check: resource.ComposeAggregateTestCheckFunc( + testAccCaptureExtensionID(t, extensionResourceName, &secondID), + testAccCheckExtensionReplaced(extensionResourceName, &firstID), + testAccCheckExtensionGone(&firstID), + resource.TestCheckResourceAttr(extensionResourceName, "name", name), + resource.TestCheckResourceAttr(extensionResourceName, "source_sha256", secondChecksum), + ), + }, + { + Config: secondConfig, + PlanOnly: true, + }, + }, + }) +} + +func testAccExtensionConfig(name, sourcePath string) string { + return acctest.ProviderConfig() + fmt.Sprintf(` +resource "kernel_extension" "test" { + name = %q + source_path = %q + source_sha256 = filesha256(%q) +} +`, name, sourcePath, sourcePath) +} + +func testAccWriteExtensionArchive(t *testing.T, marker string) (string, string) { + t.Helper() + + path := filepath.Join(t.TempDir(), "extension.zip") + file, err := os.Create(path) + if err != nil { + t.Fatalf("create extension archive: %v", err) + } + defer file.Close() + + writer := zip.NewWriter(file) + testAccWriteZipFile(t, writer, "manifest.json", `{"manifest_version":3,"name":"Kernel Terraform acceptance","version":"1.0.0"}`) + testAccWriteZipFile(t, writer, "marker.txt", marker) + if err := writer.Close(); err != nil { + t.Fatalf("close extension ZIP: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("close extension archive: %v", err) + } + + contents, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read extension archive: %v", err) + } + checksum := sha256.Sum256(contents) + return path, hex.EncodeToString(checksum[:]) +} + +func testAccWriteZipFile(t *testing.T, writer *zip.Writer, name, contents string) { + t.Helper() + + entry, err := writer.Create(name) + if err != nil { + t.Fatalf("create %s in extension ZIP: %v", name, err) + } + if _, err := entry.Write([]byte(contents)); err != nil { + t.Fatalf("write %s in extension ZIP: %v", name, err) + } +} + +func testAccCaptureExtensionID(t *testing.T, resourceName string, extensionID *string) resource.TestCheckFunc { + t.Helper() + + return func(state *terraform.State) error { + id, projectID, err := extensionStateValues(state, resourceName) + if err != nil { + return err + } + *extensionID = id + acctest.CleanupExtension(t, projectID, id) + return nil + } +} + +func testAccCheckExtensionReplaced(resourceName string, previousID *string) resource.TestCheckFunc { + return func(state *terraform.State) error { + id, _, err := extensionStateValues(state, resourceName) + if err != nil { + return err + } + if id == *previousID { + return fmt.Errorf("Kernel extension ID remained %s after content replacement", id) + } + return nil + } +} + +func testAccCheckExtensionGone(extensionID *string) resource.TestCheckFunc { + return func(*terraform.State) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + client := acctest.ClientFromEnv() + projectID := os.Getenv(acctest.EnvProjectID) + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + _, err := client.GetExtension(ctx, projectID, *extensionID) + if acctest.IsNotFound(err) { + return nil + } + if err != nil { + return fmt.Errorf("read replaced Kernel extension %s: %w", *extensionID, err) + } + + select { + case <-ctx.Done(): + return fmt.Errorf("replaced Kernel extension %s still exists after 30 seconds", *extensionID) + case <-ticker.C: + } + } + } +} + +func testAccCheckExtensionDestroyed() 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_extension" || resourceState.Primary == nil || resourceState.Primary.ID == "" { + continue + } + + projectID := resourceState.Primary.Attributes["project_id"] + _, err := client.GetExtension(ctx, projectID, resourceState.Primary.ID) + if acctest.IsNotFound(err) { + continue + } + if err != nil { + return fmt.Errorf("read Kernel extension %s after destroy: %w", resourceState.Primary.ID, err) + } + return fmt.Errorf("Kernel extension %s still exists after destroy", resourceState.Primary.ID) + } + return nil + } +} + +func extensionStateValues(state *terraform.State, resourceName string) (id, projectID string, err error) { + resourceState, ok := state.RootModule().Resources[resourceName] + if !ok { + return "", "", fmt.Errorf("missing resource %s in Terraform state", resourceName) + } + if resourceState.Primary == nil || resourceState.Primary.ID == "" { + return "", "", fmt.Errorf("missing ID for %s in Terraform state", resourceName) + } + return resourceState.Primary.ID, resourceState.Primary.Attributes["project_id"], nil +} From 0e38db9442480d7d63edf42647a4f0a62f32e15c Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 05:09:25 -0400 Subject: [PATCH 49/68] Document extension upload and import Add a Terraform 1.11-compatible extension example that tracks exact archive bytes with filesha256, publish the same snippet and import forms in generated Registry docs, and update the example index. This gives users a validated durable upload workflow without bundling a binary archive. --- docs/resources/extension.md | 32 +++++++++++++++++++ examples/README.md | 3 +- examples/extension/main.tf | 22 +++++++++++++ examples/resources/kernel_extension/import.sh | 6 ++++ .../resources/kernel_extension/resource.tf | 14 ++++++++ 5 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 examples/extension/main.tf create mode 100644 examples/resources/kernel_extension/import.sh create mode 100644 examples/resources/kernel_extension/resource.tf diff --git a/docs/resources/extension.md b/docs/resources/extension.md index a5711c6..0efe977 100644 --- a/docs/resources/extension.md +++ b/docs/resources/extension.md @@ -10,7 +10,24 @@ description: |- Kernel uploaded extension durable configuration. +## Example Usage +```terraform +terraform { + required_version = ">= 1.11.0" +} + +variable "extension_zip_path" { + type = string + description = "Path to a Chrome extension ZIP with a Manifest V3 manifest." +} + +resource "kernel_extension" "example" { + name = "productivity-tools" + source_path = var.extension_zip_path + source_sha256 = filesha256(var.extension_zip_path) +} +``` ## Schema @@ -27,3 +44,18 @@ Kernel uploaded extension durable configuration. ### Read-Only - `id` (String) Unique extension 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 +# The bare form resolves the project like create: the provider default, +# else the API key's binding. +terraform import kernel_extension.example + +# The project-qualified form imports an extension from a specific project. +terraform import kernel_extension.example / +``` diff --git a/examples/README.md b/examples/README.md index ebd6116..49badd6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,7 +2,7 @@ These examples show durable Terraform configuration only. -They do not acquire browsers, release browsers, invoke apps, fetch logs, take screenshots, open live view, upload extensions, or force-delete active runtime state. +They do not acquire browsers, release browsers, invoke apps, fetch logs, take screenshots, open live view, download extensions, or force-delete active runtime state. Set the API credential before running Terraform: @@ -22,6 +22,7 @@ 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. - [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/extension/main.tf b/examples/extension/main.tf new file mode 100644 index 0000000..c38a1f7 --- /dev/null +++ b/examples/extension/main.tf @@ -0,0 +1,22 @@ +terraform { + required_version = ">= 1.11.0" + + required_providers { + kernel = { + source = "kernel/kernel" + } + } +} + +provider "kernel" {} + +variable "extension_zip_path" { + type = string + description = "Path to a Chrome extension ZIP with a Manifest V3 manifest." +} + +resource "kernel_extension" "example" { + name = "productivity-tools" + source_path = var.extension_zip_path + source_sha256 = filesha256(var.extension_zip_path) +} diff --git a/examples/resources/kernel_extension/import.sh b/examples/resources/kernel_extension/import.sh new file mode 100644 index 0000000..b86e5d7 --- /dev/null +++ b/examples/resources/kernel_extension/import.sh @@ -0,0 +1,6 @@ +# The bare form resolves the project like create: the provider default, +# else the API key's binding. +terraform import kernel_extension.example + +# The project-qualified form imports an extension from a specific project. +terraform import kernel_extension.example / diff --git a/examples/resources/kernel_extension/resource.tf b/examples/resources/kernel_extension/resource.tf new file mode 100644 index 0000000..c0a56c3 --- /dev/null +++ b/examples/resources/kernel_extension/resource.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.11.0" +} + +variable "extension_zip_path" { + type = string + description = "Path to a Chrome extension ZIP with a Manifest V3 manifest." +} + +resource "kernel_extension" "example" { + name = "productivity-tools" + source_path = var.extension_zip_path + source_sha256 = filesha256(var.extension_zip_path) +} From 9840d0aed19e969e5ecb3bec6a5cffc43f95858f Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 05:28:13 -0400 Subject: [PATCH 50/68] Flatten resolved browser pool references Prefer the API's authoritative resolved profile ID and ordered extension IDs over echoed selectors, validate their raw response shape before writing Terraform state, and retain only an ID-based compatibility path for older responses. This prevents name selectors from becoming non-canonical durable state. --- internal/resources/browserpool/flatten.go | 54 ++++++++++-- .../resources/browserpool/flatten_test.go | 85 +++++++++++++++++-- 2 files changed, 126 insertions(+), 13 deletions(-) diff --git a/internal/resources/browserpool/flatten.go b/internal/resources/browserpool/flatten.go index 906253a..613fe83 100644 --- a/internal/resources/browserpool/flatten.go +++ b/internal/resources/browserpool/flatten.go @@ -30,9 +30,9 @@ func flattenBrowserPool(pool kernel.BrowserPool, base browserPoolModel) (browser ID: types.StringValue(pool.ID), Name: flattenName(pool, &diags), Size: types.Int64Value(config.Size), - ProfileID: types.StringNull(), + ProfileID: flattenResolvedProfileID(pool, config, &diags), ProxyID: flattenString("browser_pool_config.proxy_id", config.JSON.ProxyID.Raw(), config.JSON.ProxyID.Valid(), config.ProxyID, &diags), - ExtensionIDs: omittedExtensionIDs(config.JSON.Extensions.Raw(), base.ExtensionIDs), + ExtensionIDs: flattenResolvedExtensionIDs(pool, config, base.ExtensionIDs, &diags), ChromePolicy: omittedChromePolicy(config.JSON.ChromePolicy.Raw(), base.ChromePolicy), Viewport: types.ObjectNull(viewportAttrTypes()), Headless: flattenBool("browser_pool_config.headless", config.JSON.Headless.Raw(), config.JSON.Headless.Valid(), config.Headless, &diags), @@ -43,12 +43,6 @@ func flattenBrowserPool(pool kernel.BrowserPool, base browserPoolModel) (browser FillRatePerMinute: flattenFillRatePerMinute(config.JSON.FillRatePerMinute.Raw(), config.JSON.FillRatePerMinute.Valid(), config.FillRatePerMinute, &diags), } - if responseFieldPresent(config.JSON.Profile.Raw()) { - model.ProfileID = flattenProfileID(config.Profile, &diags) - } - if responseFieldPresent(config.JSON.Extensions.Raw()) { - model.ExtensionIDs = flattenExtensionIDs(config.JSON.Extensions.Valid(), config.Extensions, &diags) - } if responseFieldPresent(config.JSON.ChromePolicy.Raw()) { model.ChromePolicy = flattenChromePolicy(config.JSON.ChromePolicy.Raw(), &diags) } @@ -62,6 +56,32 @@ func flattenBrowserPool(pool kernel.BrowserPool, base browserPoolModel) (browser return model, diags } +func flattenResolvedProfileID(pool kernel.BrowserPool, config kernel.BrowserPoolBrowserPoolConfig, diags *diag.Diagnostics) types.String { + raw := pool.JSON.ProfileID.Raw() + if raw != "" { + if !responseFieldPresent(raw) { + addInvalidResponseDiagnostic(diags, "profile_id") + return types.StringNull() + } + return flattenString("profile_id", raw, pool.JSON.ProfileID.Valid(), pool.ProfileID, diags) + } + if responseFieldPresent(config.JSON.Profile.Raw()) { + return flattenProfileID(config.Profile, diags) + } + return types.StringNull() +} + +func flattenResolvedExtensionIDs(pool kernel.BrowserPool, config kernel.BrowserPoolBrowserPoolConfig, base types.List, diags *diag.Diagnostics) types.List { + raw := pool.JSON.ExtensionIDs.Raw() + if raw != "" { + return flattenStringList("extension_ids", raw, pool.JSON.ExtensionIDs.Valid(), pool.ExtensionIDs, diags) + } + if responseFieldPresent(config.JSON.Extensions.Raw()) { + return flattenExtensionIDs(config.JSON.Extensions.Valid(), config.Extensions, diags) + } + return omittedExtensionIDs(config.JSON.Extensions.Raw(), base) +} + func flattenName(pool kernel.BrowserPool, diags *diag.Diagnostics) types.String { if responseFieldPresent(pool.JSON.Name.Raw()) { return flattenString("name", pool.JSON.Name.Raw(), pool.JSON.Name.Valid(), pool.Name, diags) @@ -159,6 +179,24 @@ func flattenExtensionIDs(valid bool, extensions []shared.BrowserExtension, diags return list } +func flattenStringList(field, raw string, valid bool, values []string, diags *diag.Diagnostics) types.List { + var decoded []string + if !responseFieldPresent(raw) || !valid || json.Unmarshal([]byte(raw), &decoded) != nil || len(decoded) != len(values) { + addInvalidResponseDiagnostic(diags, field) + return types.ListNull(types.StringType) + } + + elements := make([]attr.Value, 0, len(values)) + for index, value := range values { + if value == "" || decoded[index] != value { + addInvalidResponseDiagnostic(diags, fmt.Sprintf("%s[%d]", field, index)) + return types.ListNull(types.StringType) + } + elements = append(elements, types.StringValue(value)) + } + return types.ListValueMust(types.StringType, elements) +} + func omittedExtensionIDs(raw string, base types.List) types.List { if raw != "" || base.IsNull() || base.IsUnknown() || len(base.Elements()) != 0 { return types.ListNull(types.StringType) diff --git a/internal/resources/browserpool/flatten_test.go b/internal/resources/browserpool/flatten_test.go index 17f65d9..e4a4c03 100644 --- a/internal/resources/browserpool/flatten_test.go +++ b/internal/resources/browserpool/flatten_test.go @@ -12,14 +12,16 @@ func TestFlattenBrowserPoolMapsDurableState(t *testing.T) { pool := unmarshalBrowserPool(t, `{ "id": "pool-1", "name": "top-level-name", + "profile_id": "profile-resolved", + "extension_ids": ["ext-resolved-b", "ext-resolved-a"], "acquired_count": 2, "available_count": 3, "browser_pool_config": { "size": 5, "name": "config-name", - "profile": {"id": "profile-1", "name": "profile-name"}, + "profile": {"name": "profile-selector"}, "proxy_id": "proxy-1", - "extensions": [{"id": "ext-b"}, {"id": "ext-a"}], + "extensions": [{"name": "extension-b"}, {"name": "extension-a"}], "chrome_policy": { "RestoreOnStartup": 4, "HomepageLocation": "https://example.com" @@ -52,13 +54,13 @@ func TestFlattenBrowserPoolMapsDurableState(t *testing.T) { if got.Size.ValueInt64() != 5 { t.Fatalf("size = %d, want 5", got.Size.ValueInt64()) } - if got.ProfileID.ValueString() != "profile-1" { - t.Fatalf("profile_id = %q, want profile-1", got.ProfileID.ValueString()) + if got.ProfileID.ValueString() != "profile-resolved" { + t.Fatalf("profile_id = %q, want profile-resolved", got.ProfileID.ValueString()) } if got.ProxyID.ValueString() != "proxy-1" { t.Fatalf("proxy_id = %q, want proxy-1", got.ProxyID.ValueString()) } - assertStringList(t, got.ExtensionIDs, []string{"ext-b", "ext-a"}) + assertStringList(t, got.ExtensionIDs, []string{"ext-resolved-b", "ext-resolved-a"}) if got.ChromePolicy.ValueString() != `{"HomepageLocation":"https://example.com","RestoreOnStartup":4}` { t.Fatalf("chrome_policy = %q, want normalized JSON", got.ChromePolicy.ValueString()) } @@ -83,6 +85,26 @@ func TestFlattenBrowserPoolMapsDurableState(t *testing.T) { } } +func TestFlattenBrowserPoolUsesLegacySelectorsWhenResolvedFieldsAreOmitted(t *testing.T) { + pool := unmarshalBrowserPool(t, `{ + "id": "pool-1", + "browser_pool_config": { + "size": 1, + "profile": {"id": "profile-legacy"}, + "extensions": [{"id": "extension-b"}, {"id": "extension-a"}] + } + }`) + + got, diags := flattenBrowserPool(pool, browserPoolModel{}) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if got.ProfileID.ValueString() != "profile-legacy" { + t.Fatalf("profile_id = %q, want profile-legacy", got.ProfileID.ValueString()) + } + assertStringList(t, got.ExtensionIDs, []string{"extension-b", "extension-a"}) +} + func TestFlattenBrowserPoolUsesConfigNameWhenTopLevelNameOmitted(t *testing.T) { pool := unmarshalBrowserPool(t, `{ "id": "pool-1", @@ -305,6 +327,59 @@ func TestFlattenBrowserPoolRejectsExtensionResponseWithoutID(t *testing.T) { } } +func TestFlattenBrowserPoolRejectsInvalidResolvedReferenceFields(t *testing.T) { + tests := map[string]string{ + "profile id is null": `{ + "id": "pool-1", + "profile_id": null, + "browser_pool_config": { + "size": 1, + "profile": {"id": "profile-fallback"} + } + }`, + "profile id has wrong type": `{ + "id": "pool-1", + "profile_id": 123, + "browser_pool_config": { + "size": 1, + "profile": {"id": "profile-fallback"} + } + }`, + "extension ids have wrong type": `{ + "id": "pool-1", + "extension_ids": {}, + "browser_pool_config": { + "size": 1, + "extensions": [{"id": "extension-fallback"}] + } + }`, + "extension ids are null": `{ + "id": "pool-1", + "extension_ids": null, + "browser_pool_config": { + "size": 1, + "extensions": [{"id": "extension-fallback"}] + } + }`, + "extension id is empty": `{ + "id": "pool-1", + "extension_ids": [""], + "browser_pool_config": { + "size": 1 + } + }`, + } + + for name, data := range tests { + t.Run(name, func(t *testing.T) { + _, diags := flattenBrowserPool(unmarshalBrowserPool(t, data), browserPoolModel{}) + if !diags.HasError() { + t.Fatal("expected diagnostics for invalid resolved reference field") + } + }) + } +} + func TestFlattenBrowserPoolRejectsInvalidScalarResponseFields(t *testing.T) { tests := map[string]string{ "string": `{ From e569d69cc44123bb7ea283bd8a0a761d5e7169b6 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 05:44:24 -0400 Subject: [PATCH 51/68] Preserve empty extension configuration intent When Kernel resolves a pool's extension IDs to an empty list, retain Terraform's prior distinction between an omitted attribute and an explicitly empty list. Validate the authoritative response first so malformed data still fails visibly, and cover both convergence paths. --- internal/resources/browserpool/flatten.go | 6 +++++- internal/resources/browserpool/flatten_test.go | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/resources/browserpool/flatten.go b/internal/resources/browserpool/flatten.go index 613fe83..18934f4 100644 --- a/internal/resources/browserpool/flatten.go +++ b/internal/resources/browserpool/flatten.go @@ -74,7 +74,11 @@ func flattenResolvedProfileID(pool kernel.BrowserPool, config kernel.BrowserPool func flattenResolvedExtensionIDs(pool kernel.BrowserPool, config kernel.BrowserPoolBrowserPoolConfig, base types.List, diags *diag.Diagnostics) types.List { raw := pool.JSON.ExtensionIDs.Raw() if raw != "" { - return flattenStringList("extension_ids", raw, pool.JSON.ExtensionIDs.Valid(), pool.ExtensionIDs, diags) + resolved := flattenStringList("extension_ids", raw, pool.JSON.ExtensionIDs.Valid(), pool.ExtensionIDs, diags) + if !resolved.IsNull() && len(pool.ExtensionIDs) == 0 { + return omittedExtensionIDs("", base) + } + return resolved } if responseFieldPresent(config.JSON.Extensions.Raw()) { return flattenExtensionIDs(config.JSON.Extensions.Valid(), config.Extensions, diags) diff --git a/internal/resources/browserpool/flatten_test.go b/internal/resources/browserpool/flatten_test.go index e4a4c03..56cea22 100644 --- a/internal/resources/browserpool/flatten_test.go +++ b/internal/resources/browserpool/flatten_test.go @@ -145,6 +145,7 @@ func TestFlattenBrowserPoolUsesConfigNameWhenTopLevelNameNull(t *testing.T) { func TestFlattenBrowserPoolNullsOmittedOptionalFields(t *testing.T) { pool := unmarshalBrowserPool(t, `{ "id": "pool-1", + "extension_ids": [], "browser_pool_config": { "size": 1 } @@ -185,9 +186,10 @@ func TestFlattenBrowserPoolNullsOmittedOptionalFields(t *testing.T) { } } -func TestFlattenBrowserPoolPreservesExplicitEmptyConfigWhenAPIOmitsIt(t *testing.T) { +func TestFlattenBrowserPoolPreservesExplicitEmptyConfigWhenResolvedIDsAreEmpty(t *testing.T) { pool := unmarshalBrowserPool(t, `{ "id": "pool-1", + "extension_ids": [], "browser_pool_config": { "size": 1 } From 2da6ca9721a64df314774dae5fd53a87908fd836 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 06:15:35 -0400 Subject: [PATCH 52/68] Add browser pool core data source Expose exact browser pool lookup by ID or name with predictable project scoping. Keep the first slice limited to canonical identity, name, and durable size so runtime state cannot enter Terraform. --- docs/data-sources/browser_pool.md | 26 ++ .../datasources/browserpool/datasource.go | 216 ++++++++++++++ .../browserpool/datasource_test.go | 276 ++++++++++++++++++ internal/provider/provider.go | 2 + internal/provider/provider_test.go | 6 +- 5 files changed, 523 insertions(+), 3 deletions(-) create mode 100644 docs/data-sources/browser_pool.md create mode 100644 internal/datasources/browserpool/datasource.go create mode 100644 internal/datasources/browserpool/datasource_test.go diff --git a/docs/data-sources/browser_pool.md b/docs/data-sources/browser_pool.md new file mode 100644 index 0000000..9b92a3c --- /dev/null +++ b/docs/data-sources/browser_pool.md @@ -0,0 +1,26 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "kernel_browser_pool Data Source - Kernel" +subcategory: "" +description: |- + Lookup durable Kernel browser pool configuration. +--- + +# kernel_browser_pool (Data Source) + +Lookup durable Kernel browser pool configuration. + + + + +## Schema + +### Optional + +- `id` (String) Browser pool ID. +- `name` (String) Browser pool name for exact lookup. +- `project_id` (String) Project to look the browser pool up in. Defaults to the provider `project_id`; when neither is set, the API key's project binding determines the project. + +### Read-Only + +- `size` (Number) Number of browsers maintained in the pool. diff --git a/internal/datasources/browserpool/datasource.go b/internal/datasources/browserpool/datasource.go new file mode 100644 index 0000000..fa044d0 --- /dev/null +++ b/internal/datasources/browserpool/datasource.go @@ -0,0 +1,216 @@ +package browserpool + +import ( + "context" + "encoding/json" + "strconv" + + "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/projectscope" +) + +var ( + _ datasource.DataSource = (*browserPoolDataSource)(nil) + _ datasource.DataSourceWithConfigure = (*browserPoolDataSource)(nil) +) + +type browserPoolClient interface { + DefaultProjectID() string + GetBrowserPool(context.Context, string, string) (*kernel.BrowserPool, error) +} + +type browserPoolDataSource struct { + client browserPoolClient +} + +type browserPoolModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + ProjectID types.String `tfsdk:"project_id"` + Size types.Int64 `tfsdk:"size"` +} + +func NewDataSource() datasource.DataSource { + return &browserPoolDataSource{} +} + +func newDataSourceWithClient(client browserPoolClient) *browserPoolDataSource { + return &browserPoolDataSource{client: client} +} + +func (d *browserPoolDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_browser_pool" +} + +func (d *browserPoolDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = dschema.Schema{ + MarkdownDescription: "Lookup durable Kernel browser pool configuration.", + Attributes: map[string]dschema.Attribute{ + "id": dschema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "Browser pool ID.", + }, + "name": dschema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "Browser pool name for exact lookup.", + }, + "project_id": dschema.StringAttribute{ + Optional: true, + MarkdownDescription: "Project to look the browser pool 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), + }, + }, + "size": dschema.Int64Attribute{ + Computed: true, + MarkdownDescription: "Number of browsers maintained in the pool.", + }, + }, + } +} + +func (d *browserPoolDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(browserPoolClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Kernel Client Type", + "Expected provider data to implement the browser pool data source durable client contract.", + ) + return + } + d.client = client +} + +func (d *browserPoolDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config browserPoolModel + 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 *browserPoolDataSource) read(ctx context.Context, config browserPoolModel) (browserPoolModel, diag.Diagnostics) { + var diags diag.Diagnostics + if d.client == nil { + diags.AddError("Missing Kernel Client", "The browser pool data source was not configured with a Kernel client.") + return browserPoolModel{}, diags + } + + selector, selectorDiags := datasources.ResolveIDNameSelector("Browser Pool", "kernel_browser_pool", config.ID, config.Name) + diags.Append(selectorDiags...) + if diags.HasError() { + return browserPoolModel{}, diags + } + + projectID := projectscope.ResolveDataSource(&diags, config.ProjectID, d.client.DefaultProjectID()) + if diags.HasError() { + return browserPoolModel{}, diags + } + + var idOrName string + switch { + case selector.HasID: + idOrName = config.ID.ValueString() + case selector.HasName: + idOrName = config.Name.ValueString() + default: + diags.AddError("Missing Browser Pool Selector", "Configure id or name for kernel_browser_pool.") + return browserPoolModel{}, diags + } + + pool, err := d.client.GetBrowserPool(ctx, projectID, idOrName) + if err != nil { + projectscope.AddError(&diags, "Read Kernel Browser Pool", projectID, err) + return browserPoolModel{}, diags + } + if pool == nil { + diags.AddError("Read Kernel Browser Pool", "Kernel returned an empty browser pool response.") + return browserPoolModel{}, diags + } + + state, flattenDiags := flattenBrowserPool(*pool) + diags.Append(flattenDiags...) + if diags.HasError() { + return browserPoolModel{}, diags + } + if selector.HasID && state.ID.ValueString() != config.ID.ValueString() { + diags.AddError( + "Browser Pool ID Mismatch", + "Kernel returned browser pool "+strconv.Quote(state.ID.ValueString())+" for id selector "+strconv.Quote(config.ID.ValueString())+".", + ) + return browserPoolModel{}, diags + } + if selector.HasName && (state.Name.IsNull() || state.Name.ValueString() != config.Name.ValueString()) { + diags.AddError( + "Browser Pool Name Mismatch", + "Kernel returned a browser pool whose name does not match exact selector "+strconv.Quote(config.Name.ValueString())+".", + ) + return browserPoolModel{}, diags + } + + state.ProjectID = config.ProjectID + return state, diags +} + +func flattenBrowserPool(pool kernel.BrowserPool) (browserPoolModel, diag.Diagnostics) { + var diags diag.Diagnostics + if !datasources.ValidResponseString(pool.JSON.ID.Raw(), pool.JSON.ID.Valid(), pool.ID) { + datasources.AddInvalidResponseField(&diags, "Browser Pool", "id") + } + if !validResponseInt64(pool.BrowserPoolConfig.JSON.Size.Raw(), pool.BrowserPoolConfig.JSON.Size.Valid(), pool.BrowserPoolConfig.Size) || pool.BrowserPoolConfig.Size < 1 { + datasources.AddInvalidResponseField(&diags, "Browser Pool", "browser_pool_config.size") + } + + name := types.StringNull() + switch { + case datasources.FieldPresent(pool.JSON.Name.Raw()): + if !datasources.ValidResponseString(pool.JSON.Name.Raw(), pool.JSON.Name.Valid(), pool.Name) { + datasources.AddInvalidResponseField(&diags, "Browser Pool", "name") + } else { + name = types.StringValue(pool.Name) + } + case datasources.FieldPresent(pool.BrowserPoolConfig.JSON.Name.Raw()): + if !datasources.ValidResponseString(pool.BrowserPoolConfig.JSON.Name.Raw(), pool.BrowserPoolConfig.JSON.Name.Valid(), pool.BrowserPoolConfig.Name) { + datasources.AddInvalidResponseField(&diags, "Browser Pool", "browser_pool_config.name") + } else { + name = types.StringValue(pool.BrowserPoolConfig.Name) + } + } + if diags.HasError() { + return browserPoolModel{}, diags + } + + return browserPoolModel{ + ID: types.StringValue(pool.ID), + Name: name, + Size: types.Int64Value(pool.BrowserPoolConfig.Size), + }, diags +} + +func validResponseInt64(raw string, valid bool, value int64) bool { + if !datasources.FieldPresent(raw) || !valid { + return false + } + var decoded int64 + return json.Unmarshal([]byte(raw), &decoded) == nil && decoded == value +} diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go new file mode 100644 index 0000000..9bde833 --- /dev/null +++ b/internal/datasources/browserpool/datasource_test.go @@ -0,0 +1,276 @@ +package browserpool + +import ( + "context" + "encoding/json" + "errors" + "strconv" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/terraform-provider-kernel/internal/kernelclient" +) + +var _ browserPoolClient = kernelclient.Clients{} + +type fakeBrowserPoolClient struct { + defaultProjectID string + get func(context.Context, string, string) (*kernel.BrowserPool, error) +} + +func (f fakeBrowserPoolClient) DefaultProjectID() string { + return f.defaultProjectID +} + +func (f fakeBrowserPoolClient) GetBrowserPool(ctx context.Context, projectID, idOrName string) (*kernel.BrowserPool, error) { + if f.get == nil { + return nil, errors.New("unexpected GetBrowserPool call") + } + return f.get(ctx, projectID, idOrName) +} + +func TestDataSourceMetadataSchemaAndConfigure(t *testing.T) { + t.Parallel() + + ds := NewDataSource() + var metadata datasource.MetadataResponse + ds.Metadata(context.Background(), datasource.MetadataRequest{ProviderTypeName: "kernel"}, &metadata) + if metadata.TypeName != "kernel_browser_pool" { + t.Fatalf("type name = %q, want kernel_browser_pool", metadata.TypeName) + } + + var schema datasource.SchemaResponse + ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema) + for _, name := range []string{"id", "name", "project_id", "size"} { + if _, ok := schema.Schema.Attributes[name]; !ok { + t.Fatalf("schema missing %s", name) + } + } + for _, runtimeField := range []string{"acquired_count", "available_count", "standby", "sessions"} { + if _, ok := schema.Schema.Attributes[runtimeField]; ok { + t.Fatalf("schema includes runtime field %s", runtimeField) + } + } + + configured := &browserPoolDataSource{} + var configure datasource.ConfigureResponse + configured.Configure(context.Background(), datasource.ConfigureRequest{ProviderData: kernelclient.Clients{}}, &configure) + if configure.Diagnostics.HasError() || configured.client == nil { + t.Fatalf("configure diagnostics/client = %v/%v", configure.Diagnostics, configured.client) + } + + var invalid datasource.ConfigureResponse + configured.Configure(context.Background(), datasource.ConfigureRequest{ProviderData: "not a client"}, &invalid) + if len(invalid.Diagnostics) != 1 || invalid.Diagnostics[0].Summary() != "Unexpected Kernel Client Type" { + t.Fatalf("invalid configure diagnostics = %v", invalid.Diagnostics) + } +} + +func TestReadBrowserPoolByIDOrName(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + config browserPoolModel + defaultProjectID string + wantProjectID string + wantSelector string + }{ + "id": { + config: browserPoolModel{ID: types.StringValue("pool-1"), ProjectID: types.StringValue("project-explicit")}, + defaultProjectID: "project-default", + wantProjectID: "project-explicit", + wantSelector: "pool-1", + }, + "name uses provider default": { + config: browserPoolModel{Name: types.StringValue("Pool"), ProjectID: types.StringNull()}, + defaultProjectID: "project-default", + wantProjectID: "project-default", + wantSelector: "Pool", + }, + "unscoped": { + config: browserPoolModel{ID: types.StringValue("pool-1"), ProjectID: types.StringNull()}, + wantSelector: "pool-1", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + var gotProjectID, gotSelector string + ds := newDataSourceWithClient(fakeBrowserPoolClient{ + defaultProjectID: test.defaultProjectID, + get: func(ctx context.Context, projectID, idOrName string) (*kernel.BrowserPool, error) { + gotProjectID, gotSelector = projectID, idOrName + return browserPoolForTest("pool-1", "Pool", 2), nil + }, + }) + + state, diags := ds.read(context.Background(), test.config) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if gotSelector != test.wantSelector { + t.Fatalf("selector = %q, want %q", gotSelector, test.wantSelector) + } + if gotProjectID != test.wantProjectID { + t.Fatalf("project = %q, want %q", gotProjectID, test.wantProjectID) + } + if state.ID.ValueString() != "pool-1" || state.Name.ValueString() != "Pool" || state.Size.ValueInt64() != 2 { + t.Fatalf("state = %#v", state) + } + if !state.ProjectID.Equal(test.config.ProjectID) { + t.Fatalf("state project_id = %v, want %v", state.ProjectID, test.config.ProjectID) + } + }) + } +} + +func TestReadSetsTerraformState(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeBrowserPoolClient{ + get: func(context.Context, string, string) (*kernel.BrowserPool, error) { + return browserPoolForTest("pool-1", "Pool", 2), nil + }, + }) + var schema datasource.SchemaResponse + ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema) + req := datasource.ReadRequest{Config: tfsdk.Config{ + Schema: schema.Schema, + Raw: browserPoolConfigValue( + tftypes.NewValue(tftypes.String, nil), + tftypes.NewValue(tftypes.String, "Pool"), + tftypes.NewValue(tftypes.String, nil), + ), + }} + resp := datasource.ReadResponse{State: tfsdk.State{Schema: schema.Schema}} + + ds.Read(context.Background(), req, &resp) + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + + var state browserPoolModel + resp.Diagnostics.Append(resp.State.Get(context.Background(), &state)...) + if resp.Diagnostics.HasError() { + t.Fatalf("read state: %v", resp.Diagnostics) + } + if state.ID.ValueString() != "pool-1" || state.Name.ValueString() != "Pool" || state.Size.ValueInt64() != 2 { + t.Fatalf("state = %#v", state) + } +} + +func TestReadBrowserPoolAllowsUnnamedPoolByID(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeBrowserPoolClient{ + get: func(context.Context, string, string) (*kernel.BrowserPool, error) { + return browserPoolForTest("pool-1", "", 1), nil + }, + }) + state, diags := ds.read(context.Background(), browserPoolModel{ID: types.StringValue("pool-1")}) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if !state.Name.IsNull() { + t.Fatalf("name = %v, want null", state.Name) + } +} + +func TestReadBrowserPoolRejectsInvalidInputsAndResponses(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + client browserPoolClient + config browserPoolModel + }{ + "missing client": {config: browserPoolModel{ID: types.StringValue("pool-1")}}, + "missing selector": {client: fakeBrowserPoolClient{}}, + "conflicting selectors": { + client: fakeBrowserPoolClient{}, + config: browserPoolModel{ID: types.StringValue("pool-1"), Name: types.StringValue("Pool")}, + }, + "unknown project": { + client: fakeBrowserPoolClient{}, + config: browserPoolModel{ID: types.StringValue("pool-1"), ProjectID: types.StringUnknown()}, + }, + "API error": { + client: fakeBrowserPoolClient{get: func(context.Context, string, string) (*kernel.BrowserPool, error) { + return nil, errors.New("connection reset") + }}, + config: browserPoolModel{ID: types.StringValue("pool-1")}, + }, + "empty response": { + client: fakeBrowserPoolClient{get: func(context.Context, string, string) (*kernel.BrowserPool, error) { return nil, nil }}, + config: browserPoolModel{ID: types.StringValue("pool-1")}, + }, + "invalid response": { + client: fakeBrowserPoolClient{get: func(context.Context, string, string) (*kernel.BrowserPool, error) { + return browserPoolFromJSON(`{"id":"pool-1","browser_pool_config":{"size":"2"}}`), nil + }}, + config: browserPoolModel{ID: types.StringValue("pool-1")}, + }, + "ID mismatch": { + client: fakeBrowserPoolClient{get: func(context.Context, string, string) (*kernel.BrowserPool, error) { + return browserPoolForTest("pool-other", "Pool", 1), nil + }}, + config: browserPoolModel{ID: types.StringValue("pool-1")}, + }, + "name mismatch": { + client: fakeBrowserPoolClient{get: func(context.Context, string, string) (*kernel.BrowserPool, error) { + return browserPoolForTest("pool-1", "Other", 1), nil + }}, + config: browserPoolModel{Name: types.StringValue("Pool")}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + ds := newDataSourceWithClient(test.client) + _, diags := ds.read(context.Background(), test.config) + if !diags.HasError() { + t.Fatal("expected diagnostics") + } + }) + } +} + +func browserPoolForTest(id, name string, size int64) *kernel.BrowserPool { + nameJSON := "null" + configName := "" + if name != "" { + nameJSON = strconv.Quote(name) + configName = `,"name":` + strconv.Quote(name) + } + return browserPoolFromJSON(`{"id":` + strconv.Quote(id) + `,"name":` + nameJSON + `,"browser_pool_config":{"size":` + strconv.FormatInt(size, 10) + configName + `}}`) +} + +func browserPoolFromJSON(body string) *kernel.BrowserPool { + var pool kernel.BrowserPool + if err := json.Unmarshal([]byte(body), &pool); err != nil { + panic(err) + } + return &pool +} + +func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value { + return tftypes.NewValue( + tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "name": tftypes.String, + "project_id": tftypes.String, + "size": tftypes.Number, + }}, + map[string]tftypes.Value{ + "id": id, + "name": name, + "project_id": projectID, + "size": tftypes.NewValue(tftypes.Number, nil), + }, + ) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index b9ed3dc..b43f0de 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" + 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" projectdatasource "github.com/kernel/terraform-provider-kernel/internal/datasources/project" @@ -90,6 +91,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{ + browserpooldatasource.NewDataSource, projectdatasource.NewDataSource, profile.NewDataSource, proxy.NewDataSource, diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 12d5a4d..92073f8 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) != 4 { - t.Fatalf("DataSources length = %d, want 4", len(dataSources)) + if len(dataSources) != 5 { + t.Fatalf("DataSources length = %d, want 5", 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_project", "kernel_profile", "kernel_proxy", "kernel_extension"} { + for _, want := range []string{"kernel_browser_pool", "kernel_project", "kernel_profile", "kernel_proxy", "kernel_extension"} { if !got[want] { t.Fatalf("missing data source %s; got %v", want, got) } From 6b2c9bce1562a170e40d1a17ca01b529e3041f65 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 06:35:58 -0400 Subject: [PATCH 53/68] Expose browser pool resolved references Read canonical profile and ordered extension IDs from the SDK response, with strict validation and ID-only fallback for legacy echoes. Keep runtime fields outside Terraform state. --- docs/data-sources/browser_pool.md | 2 + .../datasources/browserpool/datasource.go | 103 +++++++++++++- .../browserpool/datasource_test.go | 131 ++++++++++++++++-- 3 files changed, 219 insertions(+), 17 deletions(-) diff --git a/docs/data-sources/browser_pool.md b/docs/data-sources/browser_pool.md index 9b92a3c..1e7b1e9 100644 --- a/docs/data-sources/browser_pool.md +++ b/docs/data-sources/browser_pool.md @@ -23,4 +23,6 @@ Lookup durable Kernel browser pool configuration. ### Read-Only +- `extension_ids` (List of String) Resolved extension IDs attached to the pool, in load order. +- `profile_id` (String) Resolved profile ID attached to the pool, if any. - `size` (Number) Number of browsers maintained in the pool. diff --git a/internal/datasources/browserpool/datasource.go b/internal/datasources/browserpool/datasource.go index fa044d0..aa9fa0b 100644 --- a/internal/datasources/browserpool/datasource.go +++ b/internal/datasources/browserpool/datasource.go @@ -3,15 +3,18 @@ package browserpool import ( "context" "encoding/json" + "fmt" "strconv" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/datasource" dschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/shared" "github.com/kernel/terraform-provider-kernel/internal/datasources" "github.com/kernel/terraform-provider-kernel/internal/projectscope" ) @@ -31,10 +34,12 @@ type browserPoolDataSource struct { } type browserPoolModel struct { - ID types.String `tfsdk:"id"` - Name types.String `tfsdk:"name"` - ProjectID types.String `tfsdk:"project_id"` - Size types.Int64 `tfsdk:"size"` + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + ProjectID types.String `tfsdk:"project_id"` + Size types.Int64 `tfsdk:"size"` + ProfileID types.String `tfsdk:"profile_id"` + ExtensionIDs types.List `tfsdk:"extension_ids"` } func NewDataSource() datasource.DataSource { @@ -74,6 +79,15 @@ func (d *browserPoolDataSource) Schema(_ context.Context, _ datasource.SchemaReq Computed: true, MarkdownDescription: "Number of browsers maintained in the pool.", }, + "profile_id": dschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Resolved profile ID attached to the pool, if any.", + }, + "extension_ids": dschema.ListAttribute{ + Computed: true, + ElementType: types.StringType, + MarkdownDescription: "Resolved extension IDs attached to the pool, in load order.", + }, }, } } @@ -201,12 +215,87 @@ func flattenBrowserPool(pool kernel.BrowserPool) (browserPoolModel, diag.Diagnos } return browserPoolModel{ - ID: types.StringValue(pool.ID), - Name: name, - Size: types.Int64Value(pool.BrowserPoolConfig.Size), + ID: types.StringValue(pool.ID), + Name: name, + Size: types.Int64Value(pool.BrowserPoolConfig.Size), + ProfileID: flattenResolvedProfileID(pool, &diags), + ExtensionIDs: flattenResolvedExtensionIDs(pool, &diags), }, diags } +func flattenResolvedProfileID(pool kernel.BrowserPool, diags *diag.Diagnostics) types.String { + raw := pool.JSON.ProfileID.Raw() + if raw != "" { + if !datasources.FieldPresent(raw) { + datasources.AddInvalidResponseField(diags, "Browser Pool", "profile_id") + return types.StringNull() + } + if !datasources.ValidResponseString(raw, pool.JSON.ProfileID.Valid(), pool.ProfileID) { + datasources.AddInvalidResponseField(diags, "Browser Pool", "profile_id") + return types.StringNull() + } + return types.StringValue(pool.ProfileID) + } + + profile := pool.BrowserPoolConfig.Profile + if !datasources.FieldPresent(pool.BrowserPoolConfig.JSON.Profile.Raw()) { + return types.StringNull() + } + if !datasources.ValidResponseString(profile.JSON.ID.Raw(), profile.JSON.ID.Valid(), profile.ID) { + datasources.AddInvalidResponseField(diags, "Browser Pool", "browser_pool_config.profile.id") + return types.StringNull() + } + return types.StringValue(profile.ID) +} + +func flattenResolvedExtensionIDs(pool kernel.BrowserPool, diags *diag.Diagnostics) types.List { + raw := pool.JSON.ExtensionIDs.Raw() + if raw != "" { + return flattenStringList("extension_ids", raw, pool.JSON.ExtensionIDs.Valid(), pool.ExtensionIDs, diags) + } + + config := pool.BrowserPoolConfig + if !datasources.FieldPresent(config.JSON.Extensions.Raw()) { + return types.ListValueMust(types.StringType, nil) + } + return flattenExtensionIDs(config.JSON.Extensions.Valid(), config.Extensions, diags) +} + +func flattenStringList(field, raw string, valid bool, values []string, diags *diag.Diagnostics) types.List { + var decoded []string + if !datasources.FieldPresent(raw) || !valid || json.Unmarshal([]byte(raw), &decoded) != nil || len(decoded) != len(values) { + datasources.AddInvalidResponseField(diags, "Browser Pool", field) + return types.ListNull(types.StringType) + } + + elements := make([]attr.Value, 0, len(values)) + for index, value := range values { + if value == "" || decoded[index] != value { + datasources.AddInvalidResponseField(diags, "Browser Pool", fmt.Sprintf("%s[%d]", field, index)) + return types.ListNull(types.StringType) + } + elements = append(elements, types.StringValue(value)) + } + return types.ListValueMust(types.StringType, elements) +} + +func flattenExtensionIDs(valid bool, extensions []shared.BrowserExtension, diags *diag.Diagnostics) types.List { + if !valid { + datasources.AddInvalidResponseField(diags, "Browser Pool", "browser_pool_config.extensions") + return types.ListNull(types.StringType) + } + + elements := make([]attr.Value, 0, len(extensions)) + for index, extension := range extensions { + if !datasources.ValidResponseString(extension.JSON.ID.Raw(), extension.JSON.ID.Valid(), extension.ID) { + datasources.AddInvalidResponseField(diags, "Browser Pool", fmt.Sprintf("browser_pool_config.extensions[%d].id", index)) + return types.ListNull(types.StringType) + } + elements = append(elements, types.StringValue(extension.ID)) + } + return types.ListValueMust(types.StringType, elements) +} + func validResponseInt64(raw string, valid bool, value int64) bool { if !datasources.FieldPresent(raw) || !valid { return false diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go index 9bde833..345fe07 100644 --- a/internal/datasources/browserpool/datasource_test.go +++ b/internal/datasources/browserpool/datasource_test.go @@ -45,7 +45,7 @@ func TestDataSourceMetadataSchemaAndConfigure(t *testing.T) { var schema datasource.SchemaResponse ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema) - for _, name := range []string{"id", "name", "project_id", "size"} { + for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "extension_ids"} { if _, ok := schema.Schema.Attributes[name]; !ok { t.Fatalf("schema missing %s", name) } @@ -162,6 +162,96 @@ func TestReadSetsTerraformState(t *testing.T) { if state.ID.ValueString() != "pool-1" || state.Name.ValueString() != "Pool" || state.Size.ValueInt64() != 2 { t.Fatalf("state = %#v", state) } + if !state.ProfileID.IsNull() { + t.Fatalf("profile_id = %v, want null", state.ProfileID) + } + assertBrowserPoolStringList(t, state.ExtensionIDs, nil) +} + +func TestFlattenBrowserPoolResolvedReferences(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + body string + wantProfileID string + wantProfileNull bool + wantExtensionIDs []string + }{ + "authoritative fields": { + body: `{ + "id":"pool-1", + "profile_id":"profile-resolved", + "extension_ids":["extension-b","extension-a"], + "browser_pool_config":{ + "size":1, + "profile":{"name":"profile-selector"}, + "extensions":[{"name":"extension-selector-b"},{"name":"extension-selector-a"}] + } + }`, + wantProfileID: "profile-resolved", + wantExtensionIDs: []string{"extension-b", "extension-a"}, + }, + "legacy ID selectors": { + body: `{ + "id":"pool-1", + "browser_pool_config":{ + "size":1, + "profile":{"id":"profile-legacy"}, + "extensions":[{"id":"extension-b"},{"id":"extension-a"}] + } + }`, + wantProfileID: "profile-legacy", + wantExtensionIDs: []string{"extension-b", "extension-a"}, + }, + "no references": { + body: `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1}}`, + wantProfileNull: true, + wantExtensionIDs: nil, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + state, diags := flattenBrowserPool(*browserPoolFromJSON(test.body)) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if test.wantProfileNull { + if !state.ProfileID.IsNull() { + t.Fatalf("profile_id = %v, want null", state.ProfileID) + } + } else if state.ProfileID.ValueString() != test.wantProfileID { + t.Fatalf("profile_id = %q, want %q", state.ProfileID.ValueString(), test.wantProfileID) + } + assertBrowserPoolStringList(t, state.ExtensionIDs, test.wantExtensionIDs) + }) + } +} + +func TestFlattenBrowserPoolRejectsInvalidResolvedReferences(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + "null authoritative extensions": `{"id":"pool-1","extension_ids":null,"browser_pool_config":{"size":1}}`, + "non-list authoritative extensions": `{"id":"pool-1","extension_ids":{},"browser_pool_config":{"size":1}}`, + "empty authoritative extension ID": `{"id":"pool-1","extension_ids":[""],"browser_pool_config":{"size":1}}`, + "null authoritative profile ID": `{"id":"pool-1","profile_id":null,"extension_ids":[],"browser_pool_config":{"size":1}}`, + "non-string authoritative profile ID": `{"id":"pool-1","profile_id":1,"extension_ids":[],"browser_pool_config":{"size":1}}`, + "empty authoritative profile ID": `{"id":"pool-1","profile_id":"","extension_ids":[],"browser_pool_config":{"size":1}}`, + "legacy profile name only": `{"id":"pool-1","browser_pool_config":{"size":1,"profile":{"name":"profile-selector"}}}`, + "legacy extension name only": `{"id":"pool-1","browser_pool_config":{"size":1,"extensions":[{"name":"extension-selector"}]}}`, + } + + for name, body := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, diags := flattenBrowserPool(*browserPoolFromJSON(body)) + if !diags.HasError() { + t.Fatal("expected diagnostics") + } + }) + } } func TestReadBrowserPoolAllowsUnnamedPoolByID(t *testing.T) { @@ -247,7 +337,7 @@ func browserPoolForTest(id, name string, size int64) *kernel.BrowserPool { nameJSON = strconv.Quote(name) configName = `,"name":` + strconv.Quote(name) } - return browserPoolFromJSON(`{"id":` + strconv.Quote(id) + `,"name":` + nameJSON + `,"browser_pool_config":{"size":` + strconv.FormatInt(size, 10) + configName + `}}`) + return browserPoolFromJSON(`{"id":` + strconv.Quote(id) + `,"name":` + nameJSON + `,"extension_ids":[],"browser_pool_config":{"size":` + strconv.FormatInt(size, 10) + configName + `}}`) } func browserPoolFromJSON(body string) *kernel.BrowserPool { @@ -261,16 +351,37 @@ func browserPoolFromJSON(body string) *kernel.BrowserPool { func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value { return tftypes.NewValue( tftypes.Object{AttributeTypes: map[string]tftypes.Type{ - "id": tftypes.String, - "name": tftypes.String, - "project_id": tftypes.String, - "size": tftypes.Number, + "id": tftypes.String, + "name": tftypes.String, + "project_id": tftypes.String, + "size": tftypes.Number, + "profile_id": tftypes.String, + "extension_ids": tftypes.List{ElementType: tftypes.String}, }}, map[string]tftypes.Value{ - "id": id, - "name": name, - "project_id": projectID, - "size": tftypes.NewValue(tftypes.Number, nil), + "id": id, + "name": name, + "project_id": projectID, + "size": tftypes.NewValue(tftypes.Number, nil), + "profile_id": tftypes.NewValue(tftypes.String, nil), + "extension_ids": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, nil), }, ) } + +func assertBrowserPoolStringList(t *testing.T, got types.List, want []string) { + t.Helper() + if got.IsNull() || got.IsUnknown() { + t.Fatalf("list = %v, want %v", got, want) + } + elements := got.Elements() + if len(elements) != len(want) { + t.Fatalf("list length = %d, want %d", len(elements), len(want)) + } + for index, element := range elements { + value, ok := element.(types.String) + if !ok || value.ValueString() != want[index] { + t.Fatalf("list[%d] = %v, want %q", index, element, want[index]) + } + } +} From 67489e8c06161af4b0c93f8229c77a30ecabb0ef Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 07:03:06 -0400 Subject: [PATCH 54/68] Expose browser pool launch configuration Read proxy and launch-mode fields into durable data-source state. Reject explicit null or malformed SDK values and preserve known false booleans through Terraform serialization. --- docs/data-sources/browser_pool.md | 4 + .../datasources/browserpool/datasource.go | 57 ++++++++++++- .../browserpool/datasource_test.go | 83 ++++++++++++++++++- 3 files changed, 141 insertions(+), 3 deletions(-) diff --git a/docs/data-sources/browser_pool.md b/docs/data-sources/browser_pool.md index 1e7b1e9..9ff97af 100644 --- a/docs/data-sources/browser_pool.md +++ b/docs/data-sources/browser_pool.md @@ -24,5 +24,9 @@ Lookup durable Kernel browser pool configuration. ### Read-Only - `extension_ids` (List of String) Resolved extension IDs attached to the pool, in load order. +- `headless` (Boolean) Whether browsers use a headless image. +- `kiosk_mode` (Boolean) Whether browsers launch in kiosk mode. - `profile_id` (String) Resolved profile ID attached to the pool, if any. +- `proxy_id` (String) Proxy ID attached to browsers in the pool, if any. - `size` (Number) Number of browsers maintained in the pool. +- `stealth` (Boolean) Whether browsers launch in stealth mode. diff --git a/internal/datasources/browserpool/datasource.go b/internal/datasources/browserpool/datasource.go index aa9fa0b..45267fb 100644 --- a/internal/datasources/browserpool/datasource.go +++ b/internal/datasources/browserpool/datasource.go @@ -40,6 +40,10 @@ type browserPoolModel struct { Size types.Int64 `tfsdk:"size"` ProfileID types.String `tfsdk:"profile_id"` ExtensionIDs types.List `tfsdk:"extension_ids"` + ProxyID types.String `tfsdk:"proxy_id"` + Headless types.Bool `tfsdk:"headless"` + KioskMode types.Bool `tfsdk:"kiosk_mode"` + Stealth types.Bool `tfsdk:"stealth"` } func NewDataSource() datasource.DataSource { @@ -88,6 +92,22 @@ func (d *browserPoolDataSource) Schema(_ context.Context, _ datasource.SchemaReq ElementType: types.StringType, MarkdownDescription: "Resolved extension IDs attached to the pool, in load order.", }, + "proxy_id": dschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Proxy ID attached to browsers in the pool, if any.", + }, + "headless": dschema.BoolAttribute{ + Computed: true, + MarkdownDescription: "Whether browsers use a headless image.", + }, + "kiosk_mode": dschema.BoolAttribute{ + Computed: true, + MarkdownDescription: "Whether browsers launch in kiosk mode.", + }, + "stealth": dschema.BoolAttribute{ + Computed: true, + MarkdownDescription: "Whether browsers launch in stealth mode.", + }, }, } } @@ -214,15 +234,42 @@ func flattenBrowserPool(pool kernel.BrowserPool) (browserPoolModel, diag.Diagnos return browserPoolModel{}, diags } + config := pool.BrowserPoolConfig return browserPoolModel{ ID: types.StringValue(pool.ID), Name: name, - Size: types.Int64Value(pool.BrowserPoolConfig.Size), + Size: types.Int64Value(config.Size), ProfileID: flattenResolvedProfileID(pool, &diags), ExtensionIDs: flattenResolvedExtensionIDs(pool, &diags), + ProxyID: flattenOptionalString("browser_pool_config.proxy_id", config.JSON.ProxyID.Raw(), config.JSON.ProxyID.Valid(), config.ProxyID, &diags), + Headless: flattenOptionalBool("browser_pool_config.headless", config.JSON.Headless.Raw(), config.JSON.Headless.Valid(), config.Headless, &diags), + KioskMode: flattenOptionalBool("browser_pool_config.kiosk_mode", config.JSON.KioskMode.Raw(), config.JSON.KioskMode.Valid(), config.KioskMode, &diags), + Stealth: flattenOptionalBool("browser_pool_config.stealth", config.JSON.Stealth.Raw(), config.JSON.Stealth.Valid(), config.Stealth, &diags), }, diags } +func flattenOptionalString(field, raw string, valid bool, value string, diags *diag.Diagnostics) types.String { + if raw == "" { + return types.StringNull() + } + if !datasources.ValidResponseString(raw, valid, value) { + datasources.AddInvalidResponseField(diags, "Browser Pool", field) + return types.StringNull() + } + return types.StringValue(value) +} + +func flattenOptionalBool(field, raw string, valid bool, value bool, diags *diag.Diagnostics) types.Bool { + if raw == "" { + return types.BoolNull() + } + if !validResponseBool(raw, valid, value) { + datasources.AddInvalidResponseField(diags, "Browser Pool", field) + return types.BoolNull() + } + return types.BoolValue(value) +} + func flattenResolvedProfileID(pool kernel.BrowserPool, diags *diag.Diagnostics) types.String { raw := pool.JSON.ProfileID.Raw() if raw != "" { @@ -303,3 +350,11 @@ func validResponseInt64(raw string, valid bool, value int64) bool { var decoded int64 return json.Unmarshal([]byte(raw), &decoded) == nil && decoded == value } + +func validResponseBool(raw string, valid bool, value bool) bool { + if !datasources.FieldPresent(raw) || !valid { + return false + } + var decoded bool + return json.Unmarshal([]byte(raw), &decoded) == nil && decoded == value +} diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go index 345fe07..9322b71 100644 --- a/internal/datasources/browserpool/datasource_test.go +++ b/internal/datasources/browserpool/datasource_test.go @@ -45,7 +45,7 @@ func TestDataSourceMetadataSchemaAndConfigure(t *testing.T) { var schema datasource.SchemaResponse ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema) - for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "extension_ids"} { + for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "extension_ids", "proxy_id", "headless", "kiosk_mode", "stealth"} { if _, ok := schema.Schema.Attributes[name]; !ok { t.Fatalf("schema missing %s", name) } @@ -134,7 +134,18 @@ func TestReadSetsTerraformState(t *testing.T) { ds := newDataSourceWithClient(fakeBrowserPoolClient{ get: func(context.Context, string, string) (*kernel.BrowserPool, error) { - return browserPoolForTest("pool-1", "Pool", 2), nil + return browserPoolFromJSON(`{ + "id":"pool-1", + "name":"Pool", + "extension_ids":[], + "browser_pool_config":{ + "size":2, + "proxy_id":"proxy-1", + "headless":true, + "kiosk_mode":false, + "stealth":true + } + }`), nil }, }) var schema datasource.SchemaResponse @@ -166,6 +177,66 @@ func TestReadSetsTerraformState(t *testing.T) { t.Fatalf("profile_id = %v, want null", state.ProfileID) } assertBrowserPoolStringList(t, state.ExtensionIDs, nil) + if state.ProxyID.ValueString() != "proxy-1" || !state.Headless.ValueBool() || state.KioskMode.IsNull() || state.KioskMode.ValueBool() || !state.Stealth.ValueBool() { + t.Fatalf("launch state = %#v", state) + } +} + +func TestFlattenBrowserPoolLaunchConfiguration(t *testing.T) { + t.Parallel() + + state, diags := flattenBrowserPool(*browserPoolFromJSON(`{ + "id":"pool-1", + "extension_ids":[], + "browser_pool_config":{ + "size":1, + "proxy_id":"proxy-1", + "headless":true, + "kiosk_mode":false, + "stealth":true + } + }`)) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if state.ProxyID.ValueString() != "proxy-1" { + t.Fatalf("proxy_id = %q, want proxy-1", state.ProxyID.ValueString()) + } + if !state.Headless.ValueBool() { + t.Fatal("headless = false, want true") + } + if state.KioskMode.IsNull() || state.KioskMode.ValueBool() { + t.Fatalf("kiosk_mode = %v, want known false", state.KioskMode) + } + if !state.Stealth.ValueBool() { + t.Fatal("stealth = false, want true") + } +} + +func TestFlattenBrowserPoolRejectsInvalidLaunchConfiguration(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + "empty proxy ID": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"proxy_id":""}}`, + "null proxy ID": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"proxy_id":null}}`, + "non-string proxy": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"proxy_id":1}}`, + "null headless": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"headless":null}}`, + "non-bool headless": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"headless":"true"}}`, + "null kiosk": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"kiosk_mode":null}}`, + "non-bool kiosk": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"kiosk_mode":1}}`, + "null stealth": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"stealth":null}}`, + "non-bool stealth": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"stealth":{}}}`, + } + + for name, body := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, diags := flattenBrowserPool(*browserPoolFromJSON(body)) + if !diags.HasError() { + t.Fatal("expected diagnostics") + } + }) + } } func TestFlattenBrowserPoolResolvedReferences(t *testing.T) { @@ -357,6 +428,10 @@ func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value { "size": tftypes.Number, "profile_id": tftypes.String, "extension_ids": tftypes.List{ElementType: tftypes.String}, + "proxy_id": tftypes.String, + "headless": tftypes.Bool, + "kiosk_mode": tftypes.Bool, + "stealth": tftypes.Bool, }}, map[string]tftypes.Value{ "id": id, @@ -365,6 +440,10 @@ func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value { "size": tftypes.NewValue(tftypes.Number, nil), "profile_id": tftypes.NewValue(tftypes.String, nil), "extension_ids": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, nil), + "proxy_id": tftypes.NewValue(tftypes.String, nil), + "headless": tftypes.NewValue(tftypes.Bool, nil), + "kiosk_mode": tftypes.NewValue(tftypes.Bool, nil), + "stealth": tftypes.NewValue(tftypes.Bool, nil), }, ) } From 1bcac42ba0bd797bc7cbfbf46d91bd3134f08e36 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 07:15:39 -0400 Subject: [PATCH 55/68] Expose browser pool warmup configuration Read start URL, timeout, and fill rate into durable data-source state. Validate SDK response types and ranges while preserving omitted values and known zero. --- docs/data-sources/browser_pool.md | 3 + .../datasources/browserpool/datasource.go | 97 +++++++++++++---- .../browserpool/datasource_test.go | 103 ++++++++++++++---- 3 files changed, 162 insertions(+), 41 deletions(-) diff --git a/docs/data-sources/browser_pool.md b/docs/data-sources/browser_pool.md index 9ff97af..5f05ade 100644 --- a/docs/data-sources/browser_pool.md +++ b/docs/data-sources/browser_pool.md @@ -24,9 +24,12 @@ Lookup durable Kernel browser pool configuration. ### Read-Only - `extension_ids` (List of String) Resolved extension IDs attached to the pool, in load order. +- `fill_rate_per_minute` (Number) Percentage of the pool filled per minute. - `headless` (Boolean) Whether browsers use a headless image. - `kiosk_mode` (Boolean) Whether browsers launch in kiosk mode. - `profile_id` (String) Resolved profile ID attached to the pool, if any. - `proxy_id` (String) Proxy ID attached to browsers in the pool, if any. - `size` (Number) Number of browsers maintained in the pool. +- `start_url` (String) URL opened when a browser is warmed into the pool, if configured. - `stealth` (Boolean) Whether browsers launch in stealth mode. +- `timeout_seconds` (Number) Default idle timeout in seconds for acquired browsers. diff --git a/internal/datasources/browserpool/datasource.go b/internal/datasources/browserpool/datasource.go index 45267fb..9a9ed8d 100644 --- a/internal/datasources/browserpool/datasource.go +++ b/internal/datasources/browserpool/datasource.go @@ -24,6 +24,12 @@ var ( _ datasource.DataSourceWithConfigure = (*browserPoolDataSource)(nil) ) +const ( + minBrowserPoolTimeoutSeconds = 10 + maxBrowserPoolTimeoutSeconds = 259200 + minBrowserPoolFillRate = 0 +) + type browserPoolClient interface { DefaultProjectID() string GetBrowserPool(context.Context, string, string) (*kernel.BrowserPool, error) @@ -34,16 +40,19 @@ type browserPoolDataSource struct { } type browserPoolModel struct { - ID types.String `tfsdk:"id"` - Name types.String `tfsdk:"name"` - ProjectID types.String `tfsdk:"project_id"` - Size types.Int64 `tfsdk:"size"` - ProfileID types.String `tfsdk:"profile_id"` - ExtensionIDs types.List `tfsdk:"extension_ids"` - ProxyID types.String `tfsdk:"proxy_id"` - Headless types.Bool `tfsdk:"headless"` - KioskMode types.Bool `tfsdk:"kiosk_mode"` - Stealth types.Bool `tfsdk:"stealth"` + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + ProjectID types.String `tfsdk:"project_id"` + Size types.Int64 `tfsdk:"size"` + ProfileID types.String `tfsdk:"profile_id"` + ExtensionIDs types.List `tfsdk:"extension_ids"` + ProxyID types.String `tfsdk:"proxy_id"` + Headless types.Bool `tfsdk:"headless"` + KioskMode types.Bool `tfsdk:"kiosk_mode"` + Stealth types.Bool `tfsdk:"stealth"` + StartURL types.String `tfsdk:"start_url"` + TimeoutSeconds types.Int64 `tfsdk:"timeout_seconds"` + FillRatePerMinute types.Int64 `tfsdk:"fill_rate_per_minute"` } func NewDataSource() datasource.DataSource { @@ -108,6 +117,18 @@ func (d *browserPoolDataSource) Schema(_ context.Context, _ datasource.SchemaReq Computed: true, MarkdownDescription: "Whether browsers launch in stealth mode.", }, + "start_url": dschema.StringAttribute{ + Computed: true, + MarkdownDescription: "URL opened when a browser is warmed into the pool, if configured.", + }, + "timeout_seconds": dschema.Int64Attribute{ + Computed: true, + MarkdownDescription: "Default idle timeout in seconds for acquired browsers.", + }, + "fill_rate_per_minute": dschema.Int64Attribute{ + Computed: true, + MarkdownDescription: "Percentage of the pool filled per minute.", + }, }, } } @@ -236,15 +257,18 @@ func flattenBrowserPool(pool kernel.BrowserPool) (browserPoolModel, diag.Diagnos config := pool.BrowserPoolConfig return browserPoolModel{ - ID: types.StringValue(pool.ID), - Name: name, - Size: types.Int64Value(config.Size), - ProfileID: flattenResolvedProfileID(pool, &diags), - ExtensionIDs: flattenResolvedExtensionIDs(pool, &diags), - ProxyID: flattenOptionalString("browser_pool_config.proxy_id", config.JSON.ProxyID.Raw(), config.JSON.ProxyID.Valid(), config.ProxyID, &diags), - Headless: flattenOptionalBool("browser_pool_config.headless", config.JSON.Headless.Raw(), config.JSON.Headless.Valid(), config.Headless, &diags), - KioskMode: flattenOptionalBool("browser_pool_config.kiosk_mode", config.JSON.KioskMode.Raw(), config.JSON.KioskMode.Valid(), config.KioskMode, &diags), - Stealth: flattenOptionalBool("browser_pool_config.stealth", config.JSON.Stealth.Raw(), config.JSON.Stealth.Valid(), config.Stealth, &diags), + ID: types.StringValue(pool.ID), + Name: name, + Size: types.Int64Value(config.Size), + ProfileID: flattenResolvedProfileID(pool, &diags), + ExtensionIDs: flattenResolvedExtensionIDs(pool, &diags), + ProxyID: flattenOptionalString("browser_pool_config.proxy_id", config.JSON.ProxyID.Raw(), config.JSON.ProxyID.Valid(), config.ProxyID, &diags), + Headless: flattenOptionalBool("browser_pool_config.headless", config.JSON.Headless.Raw(), config.JSON.Headless.Valid(), config.Headless, &diags), + KioskMode: flattenOptionalBool("browser_pool_config.kiosk_mode", config.JSON.KioskMode.Raw(), config.JSON.KioskMode.Valid(), config.KioskMode, &diags), + Stealth: flattenOptionalBool("browser_pool_config.stealth", config.JSON.Stealth.Raw(), config.JSON.Stealth.Valid(), config.Stealth, &diags), + StartURL: flattenOptionalString("browser_pool_config.start_url", config.JSON.StartURL.Raw(), config.JSON.StartURL.Valid(), config.StartURL, &diags), + TimeoutSeconds: flattenTimeoutSeconds(config.JSON.TimeoutSeconds.Raw(), config.JSON.TimeoutSeconds.Valid(), config.TimeoutSeconds, &diags), + FillRatePerMinute: flattenFillRatePerMinute(config.JSON.FillRatePerMinute.Raw(), config.JSON.FillRatePerMinute.Valid(), config.FillRatePerMinute, &diags), }, diags } @@ -270,6 +294,41 @@ func flattenOptionalBool(field, raw string, valid bool, value bool, diags *diag. return types.BoolValue(value) } +func flattenTimeoutSeconds(raw string, valid bool, value int64, diags *diag.Diagnostics) types.Int64 { + result := flattenOptionalInt64("browser_pool_config.timeout_seconds", raw, valid, value, diags) + if result.IsNull() { + return result + } + if value < minBrowserPoolTimeoutSeconds || value > maxBrowserPoolTimeoutSeconds { + datasources.AddInvalidResponseField(diags, "Browser Pool", "browser_pool_config.timeout_seconds") + return types.Int64Null() + } + return result +} + +func flattenFillRatePerMinute(raw string, valid bool, value int64, diags *diag.Diagnostics) types.Int64 { + result := flattenOptionalInt64("browser_pool_config.fill_rate_per_minute", raw, valid, value, diags) + if result.IsNull() { + return result + } + if value < minBrowserPoolFillRate { + datasources.AddInvalidResponseField(diags, "Browser Pool", "browser_pool_config.fill_rate_per_minute") + return types.Int64Null() + } + return result +} + +func flattenOptionalInt64(field, raw string, valid bool, value int64, diags *diag.Diagnostics) types.Int64 { + if raw == "" { + return types.Int64Null() + } + if !validResponseInt64(raw, valid, value) { + datasources.AddInvalidResponseField(diags, "Browser Pool", field) + return types.Int64Null() + } + return types.Int64Value(value) +} + func flattenResolvedProfileID(pool kernel.BrowserPool, diags *diag.Diagnostics) types.String { raw := pool.JSON.ProfileID.Raw() if raw != "" { diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go index 9322b71..9051df3 100644 --- a/internal/datasources/browserpool/datasource_test.go +++ b/internal/datasources/browserpool/datasource_test.go @@ -45,7 +45,7 @@ func TestDataSourceMetadataSchemaAndConfigure(t *testing.T) { var schema datasource.SchemaResponse ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema) - for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "extension_ids", "proxy_id", "headless", "kiosk_mode", "stealth"} { + for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "extension_ids", "proxy_id", "headless", "kiosk_mode", "stealth", "start_url", "timeout_seconds", "fill_rate_per_minute"} { if _, ok := schema.Schema.Attributes[name]; !ok { t.Fatalf("schema missing %s", name) } @@ -143,7 +143,10 @@ func TestReadSetsTerraformState(t *testing.T) { "proxy_id":"proxy-1", "headless":true, "kiosk_mode":false, - "stealth":true + "stealth":true, + "start_url":"chrome://newtab", + "timeout_seconds":10, + "fill_rate_per_minute":0 } }`), nil }, @@ -180,6 +183,56 @@ func TestReadSetsTerraformState(t *testing.T) { if state.ProxyID.ValueString() != "proxy-1" || !state.Headless.ValueBool() || state.KioskMode.IsNull() || state.KioskMode.ValueBool() || !state.Stealth.ValueBool() { t.Fatalf("launch state = %#v", state) } + if state.StartURL.ValueString() != "chrome://newtab" || state.TimeoutSeconds.ValueInt64() != 10 || state.FillRatePerMinute.IsNull() || state.FillRatePerMinute.IsUnknown() || state.FillRatePerMinute.ValueInt64() != 0 { + t.Fatalf("warmup state = %#v", state) + } +} + +func TestFlattenBrowserPoolWarmupConfigurationBoundaries(t *testing.T) { + t.Parallel() + + omitted, diags := flattenBrowserPool(*browserPoolFromJSON(`{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1}}`)) + if diags.HasError() { + t.Fatalf("unexpected omitted-field diagnostics: %v", diags) + } + if !omitted.StartURL.IsNull() || !omitted.TimeoutSeconds.IsNull() || !omitted.FillRatePerMinute.IsNull() { + t.Fatalf("omitted warmup state = %#v, want null values", omitted) + } + + boundary, diags := flattenBrowserPool(*browserPoolFromJSON(`{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"timeout_seconds":259200,"fill_rate_per_minute":0}}`)) + if diags.HasError() { + t.Fatalf("unexpected boundary diagnostics: %v", diags) + } + if boundary.TimeoutSeconds.ValueInt64() != 259200 || boundary.FillRatePerMinute.IsNull() || boundary.FillRatePerMinute.IsUnknown() || boundary.FillRatePerMinute.ValueInt64() != 0 { + t.Fatalf("boundary warmup state = %#v", boundary) + } +} + +func TestFlattenBrowserPoolRejectsInvalidWarmupConfiguration(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + "empty start URL": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"start_url":""}}`, + "null start URL": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"start_url":null}}`, + "non-string URL": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"start_url":1}}`, + "null timeout": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"timeout_seconds":null}}`, + "non-number timeout": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"timeout_seconds":"10"}}`, + "timeout too low": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"timeout_seconds":9}}`, + "timeout too high": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"timeout_seconds":259201}}`, + "null fill rate": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"fill_rate_per_minute":null}}`, + "non-number rate": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"fill_rate_per_minute":"0"}}`, + "negative fill rate": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"fill_rate_per_minute":-1}}`, + } + + for name, body := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, diags := flattenBrowserPool(*browserPoolFromJSON(body)) + if !diags.HasError() { + t.Fatal("expected diagnostics") + } + }) + } } func TestFlattenBrowserPoolLaunchConfiguration(t *testing.T) { @@ -422,28 +475,34 @@ func browserPoolFromJSON(body string) *kernel.BrowserPool { func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value { return tftypes.NewValue( tftypes.Object{AttributeTypes: map[string]tftypes.Type{ - "id": tftypes.String, - "name": tftypes.String, - "project_id": tftypes.String, - "size": tftypes.Number, - "profile_id": tftypes.String, - "extension_ids": tftypes.List{ElementType: tftypes.String}, - "proxy_id": tftypes.String, - "headless": tftypes.Bool, - "kiosk_mode": tftypes.Bool, - "stealth": tftypes.Bool, + "id": tftypes.String, + "name": tftypes.String, + "project_id": tftypes.String, + "size": tftypes.Number, + "profile_id": tftypes.String, + "extension_ids": tftypes.List{ElementType: tftypes.String}, + "proxy_id": tftypes.String, + "headless": tftypes.Bool, + "kiosk_mode": tftypes.Bool, + "stealth": tftypes.Bool, + "start_url": tftypes.String, + "timeout_seconds": tftypes.Number, + "fill_rate_per_minute": tftypes.Number, }}, map[string]tftypes.Value{ - "id": id, - "name": name, - "project_id": projectID, - "size": tftypes.NewValue(tftypes.Number, nil), - "profile_id": tftypes.NewValue(tftypes.String, nil), - "extension_ids": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, nil), - "proxy_id": tftypes.NewValue(tftypes.String, nil), - "headless": tftypes.NewValue(tftypes.Bool, nil), - "kiosk_mode": tftypes.NewValue(tftypes.Bool, nil), - "stealth": tftypes.NewValue(tftypes.Bool, nil), + "id": id, + "name": name, + "project_id": projectID, + "size": tftypes.NewValue(tftypes.Number, nil), + "profile_id": tftypes.NewValue(tftypes.String, nil), + "extension_ids": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, nil), + "proxy_id": tftypes.NewValue(tftypes.String, nil), + "headless": tftypes.NewValue(tftypes.Bool, nil), + "kiosk_mode": tftypes.NewValue(tftypes.Bool, nil), + "stealth": tftypes.NewValue(tftypes.Bool, nil), + "start_url": tftypes.NewValue(tftypes.String, nil), + "timeout_seconds": tftypes.NewValue(tftypes.Number, nil), + "fill_rate_per_minute": tftypes.NewValue(tftypes.Number, nil), }, ) } From 1b81db144e3f043402743f6e7ade3bc47293974a Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 07:27:36 -0400 Subject: [PATCH 56/68] Expose browser pool viewport Read viewport configuration into a typed Terraform object. Reject malformed dimensions and preserve omitted viewport and refresh-rate values without partial state. --- docs/data-sources/browser_pool.md | 10 +++ .../datasources/browserpool/datasource.go | 55 +++++++++++++++ .../browserpool/datasource_test.go | 68 ++++++++++++++++++- 3 files changed, 131 insertions(+), 2 deletions(-) diff --git a/docs/data-sources/browser_pool.md b/docs/data-sources/browser_pool.md index 5f05ade..7c6dccd 100644 --- a/docs/data-sources/browser_pool.md +++ b/docs/data-sources/browser_pool.md @@ -33,3 +33,13 @@ Lookup durable Kernel browser pool configuration. - `start_url` (String) URL opened when a browser is warmed into the pool, if configured. - `stealth` (Boolean) Whether browsers launch in stealth mode. - `timeout_seconds` (Number) Default idle timeout in seconds for acquired browsers. +- `viewport` (Attributes) Browser viewport configured for the pool, if any. (see [below for nested schema](#nestedatt--viewport)) + + +### Nested Schema for `viewport` + +Read-Only: + +- `height` (Number) Browser window height in pixels. +- `refresh_rate` (Number) Display refresh rate in Hz, if configured. +- `width` (Number) Browser window width in pixels. diff --git a/internal/datasources/browserpool/datasource.go b/internal/datasources/browserpool/datasource.go index 9a9ed8d..07fd0db 100644 --- a/internal/datasources/browserpool/datasource.go +++ b/internal/datasources/browserpool/datasource.go @@ -28,6 +28,7 @@ const ( minBrowserPoolTimeoutSeconds = 10 maxBrowserPoolTimeoutSeconds = 259200 minBrowserPoolFillRate = 0 + minBrowserPoolViewportValue = 1 ) type browserPoolClient interface { @@ -53,6 +54,7 @@ type browserPoolModel struct { StartURL types.String `tfsdk:"start_url"` TimeoutSeconds types.Int64 `tfsdk:"timeout_seconds"` FillRatePerMinute types.Int64 `tfsdk:"fill_rate_per_minute"` + Viewport types.Object `tfsdk:"viewport"` } func NewDataSource() datasource.DataSource { @@ -129,6 +131,15 @@ func (d *browserPoolDataSource) Schema(_ context.Context, _ datasource.SchemaReq Computed: true, MarkdownDescription: "Percentage of the pool filled per minute.", }, + "viewport": dschema.SingleNestedAttribute{ + Computed: true, + MarkdownDescription: "Browser viewport configured for the pool, if any.", + Attributes: map[string]dschema.Attribute{ + "width": dschema.Int64Attribute{Computed: true, MarkdownDescription: "Browser window width in pixels."}, + "height": dschema.Int64Attribute{Computed: true, MarkdownDescription: "Browser window height in pixels."}, + "refresh_rate": dschema.Int64Attribute{Computed: true, MarkdownDescription: "Display refresh rate in Hz, if configured."}, + }, + }, }, } } @@ -269,6 +280,7 @@ func flattenBrowserPool(pool kernel.BrowserPool) (browserPoolModel, diag.Diagnos StartURL: flattenOptionalString("browser_pool_config.start_url", config.JSON.StartURL.Raw(), config.JSON.StartURL.Valid(), config.StartURL, &diags), TimeoutSeconds: flattenTimeoutSeconds(config.JSON.TimeoutSeconds.Raw(), config.JSON.TimeoutSeconds.Valid(), config.TimeoutSeconds, &diags), FillRatePerMinute: flattenFillRatePerMinute(config.JSON.FillRatePerMinute.Raw(), config.JSON.FillRatePerMinute.Valid(), config.FillRatePerMinute, &diags), + Viewport: flattenViewport(config.JSON.Viewport.Raw(), config.JSON.Viewport.Valid(), config.Viewport, &diags), }, diags } @@ -329,6 +341,49 @@ func flattenOptionalInt64(field, raw string, valid bool, value int64, diags *dia return types.Int64Value(value) } +func flattenViewport(raw string, valid bool, viewport shared.BrowserViewport, diags *diag.Diagnostics) types.Object { + if raw == "" { + return types.ObjectNull(viewportAttributeTypes()) + } + if !datasources.FieldPresent(raw) || !valid { + datasources.AddInvalidResponseField(diags, "Browser Pool", "browser_pool_config.viewport") + return types.ObjectNull(viewportAttributeTypes()) + } + + diagnosticsBefore := len(*diags) + width := flattenRequiredPositiveInt64("browser_pool_config.viewport.width", viewport.JSON.Width.Raw(), viewport.JSON.Width.Valid(), viewport.Width, diags) + height := flattenRequiredPositiveInt64("browser_pool_config.viewport.height", viewport.JSON.Height.Raw(), viewport.JSON.Height.Valid(), viewport.Height, diags) + refreshRate := types.Int64Null() + if viewport.JSON.RefreshRate.Raw() != "" { + refreshRate = flattenRequiredPositiveInt64("browser_pool_config.viewport.refresh_rate", viewport.JSON.RefreshRate.Raw(), viewport.JSON.RefreshRate.Valid(), viewport.RefreshRate, diags) + } + if len(*diags) > diagnosticsBefore { + return types.ObjectNull(viewportAttributeTypes()) + } + + return types.ObjectValueMust(viewportAttributeTypes(), map[string]attr.Value{ + "width": width, + "height": height, + "refresh_rate": refreshRate, + }) +} + +func flattenRequiredPositiveInt64(field, raw string, valid bool, value int64, diags *diag.Diagnostics) types.Int64 { + if !validResponseInt64(raw, valid, value) || value < minBrowserPoolViewportValue { + datasources.AddInvalidResponseField(diags, "Browser Pool", field) + return types.Int64Null() + } + return types.Int64Value(value) +} + +func viewportAttributeTypes() map[string]attr.Type { + return map[string]attr.Type{ + "width": types.Int64Type, + "height": types.Int64Type, + "refresh_rate": types.Int64Type, + } +} + func flattenResolvedProfileID(pool kernel.BrowserPool, diags *diag.Diagnostics) types.String { raw := pool.JSON.ProfileID.Raw() if raw != "" { diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go index 9051df3..023b2d9 100644 --- a/internal/datasources/browserpool/datasource_test.go +++ b/internal/datasources/browserpool/datasource_test.go @@ -45,7 +45,7 @@ func TestDataSourceMetadataSchemaAndConfigure(t *testing.T) { var schema datasource.SchemaResponse ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema) - for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "extension_ids", "proxy_id", "headless", "kiosk_mode", "stealth", "start_url", "timeout_seconds", "fill_rate_per_minute"} { + for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "extension_ids", "proxy_id", "headless", "kiosk_mode", "stealth", "start_url", "timeout_seconds", "fill_rate_per_minute", "viewport"} { if _, ok := schema.Schema.Attributes[name]; !ok { t.Fatalf("schema missing %s", name) } @@ -146,7 +146,8 @@ func TestReadSetsTerraformState(t *testing.T) { "stealth":true, "start_url":"chrome://newtab", "timeout_seconds":10, - "fill_rate_per_minute":0 + "fill_rate_per_minute":0, + "viewport":{"width":1280,"height":800,"refresh_rate":60} } }`), nil }, @@ -186,6 +187,51 @@ func TestReadSetsTerraformState(t *testing.T) { if state.StartURL.ValueString() != "chrome://newtab" || state.TimeoutSeconds.ValueInt64() != 10 || state.FillRatePerMinute.IsNull() || state.FillRatePerMinute.IsUnknown() || state.FillRatePerMinute.ValueInt64() != 0 { t.Fatalf("warmup state = %#v", state) } + assertBrowserPoolViewport(t, state.Viewport, 1280, 800, types.Int64Value(60)) +} + +func TestFlattenBrowserPoolViewportOptionalFields(t *testing.T) { + t.Parallel() + + omitted, diags := flattenBrowserPool(*browserPoolFromJSON(`{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1}}`)) + if diags.HasError() { + t.Fatalf("unexpected omitted viewport diagnostics: %v", diags) + } + if !omitted.Viewport.IsNull() || len(omitted.Viewport.AttributeTypes(t.Context())) != 3 { + t.Fatalf("omitted viewport = %#v, want typed null", omitted.Viewport) + } + + withoutRefreshRate, diags := flattenBrowserPool(*browserPoolFromJSON(`{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":1280,"height":800}}}`)) + if diags.HasError() { + t.Fatalf("unexpected viewport diagnostics: %v", diags) + } + assertBrowserPoolViewport(t, withoutRefreshRate.Viewport, 1280, 800, types.Int64Null()) +} + +func TestFlattenBrowserPoolRejectsInvalidViewport(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + "null viewport": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":null}}`, + "non-object viewport": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":[]}}`, + "missing width": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"height":800}}}`, + "missing height": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":1280}}}`, + "zero width": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":0,"height":800}}}`, + "negative height": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":1280,"height":-1}}}`, + "null refresh rate": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":1280,"height":800,"refresh_rate":null}}}`, + "zero refresh rate": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":1280,"height":800,"refresh_rate":0}}}`, + "non-number dimension": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":"1280","height":800}}}`, + } + + for name, body := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, diags := flattenBrowserPool(*browserPoolFromJSON(body)) + if !diags.HasError() { + t.Fatal("expected diagnostics") + } + }) + } } func TestFlattenBrowserPoolWarmupConfigurationBoundaries(t *testing.T) { @@ -473,6 +519,11 @@ func browserPoolFromJSON(body string) *kernel.BrowserPool { } func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value { + viewportType := tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "width": tftypes.Number, + "height": tftypes.Number, + "refresh_rate": tftypes.Number, + }} return tftypes.NewValue( tftypes.Object{AttributeTypes: map[string]tftypes.Type{ "id": tftypes.String, @@ -488,6 +539,7 @@ func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value { "start_url": tftypes.String, "timeout_seconds": tftypes.Number, "fill_rate_per_minute": tftypes.Number, + "viewport": viewportType, }}, map[string]tftypes.Value{ "id": id, @@ -503,10 +555,22 @@ func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value { "start_url": tftypes.NewValue(tftypes.String, nil), "timeout_seconds": tftypes.NewValue(tftypes.Number, nil), "fill_rate_per_minute": tftypes.NewValue(tftypes.Number, nil), + "viewport": tftypes.NewValue(viewportType, nil), }, ) } +func assertBrowserPoolViewport(t *testing.T, viewport types.Object, width, height int64, refreshRate types.Int64) { + t.Helper() + if viewport.IsNull() || viewport.IsUnknown() { + t.Fatalf("viewport = %#v, want known object", viewport) + } + attributes := viewport.Attributes() + if !attributes["width"].(types.Int64).Equal(types.Int64Value(width)) || !attributes["height"].(types.Int64).Equal(types.Int64Value(height)) || !attributes["refresh_rate"].(types.Int64).Equal(refreshRate) { + t.Fatalf("viewport = %#v, want %dx%d refresh %v", viewport, width, height, refreshRate) + } +} + func assertBrowserPoolStringList(t *testing.T, got types.List, want []string) { t.Helper() if got.IsNull() || got.IsUnknown() { From a0a227d83c3883448573f73f92851ce1a4eda874 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 07:49:57 -0400 Subject: [PATCH 57/68] Expose browser pool Chrome policy Normalize SDK Chrome-policy JSON into stable Terraform string state. Keep loose maps at the response boundary and preserve absent or null policy compatibility. --- docs/data-sources/browser_pool.md | 1 + .../datasources/browserpool/datasource.go | 33 ++++++++++++ .../browserpool/datasource_test.go | 52 ++++++++++++++++++- 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/docs/data-sources/browser_pool.md b/docs/data-sources/browser_pool.md index 7c6dccd..7b803bf 100644 --- a/docs/data-sources/browser_pool.md +++ b/docs/data-sources/browser_pool.md @@ -23,6 +23,7 @@ Lookup durable Kernel browser pool configuration. ### Read-Only +- `chrome_policy` (String) Normalized JSON object of Chrome enterprise policy overrides, if configured. - `extension_ids` (List of String) Resolved extension IDs attached to the pool, in load order. - `fill_rate_per_minute` (Number) Percentage of the pool filled per minute. - `headless` (Boolean) Whether browsers use a headless image. diff --git a/internal/datasources/browserpool/datasource.go b/internal/datasources/browserpool/datasource.go index 07fd0db..e41ccc6 100644 --- a/internal/datasources/browserpool/datasource.go +++ b/internal/datasources/browserpool/datasource.go @@ -1,10 +1,12 @@ package browserpool import ( + "bytes" "context" "encoding/json" "fmt" "strconv" + "strings" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" @@ -55,6 +57,7 @@ type browserPoolModel struct { TimeoutSeconds types.Int64 `tfsdk:"timeout_seconds"` FillRatePerMinute types.Int64 `tfsdk:"fill_rate_per_minute"` Viewport types.Object `tfsdk:"viewport"` + ChromePolicy types.String `tfsdk:"chrome_policy"` } func NewDataSource() datasource.DataSource { @@ -140,6 +143,10 @@ func (d *browserPoolDataSource) Schema(_ context.Context, _ datasource.SchemaReq "refresh_rate": dschema.Int64Attribute{Computed: true, MarkdownDescription: "Display refresh rate in Hz, if configured."}, }, }, + "chrome_policy": dschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Normalized JSON object of Chrome enterprise policy overrides, if configured.", + }, }, } } @@ -281,6 +288,7 @@ func flattenBrowserPool(pool kernel.BrowserPool) (browserPoolModel, diag.Diagnos TimeoutSeconds: flattenTimeoutSeconds(config.JSON.TimeoutSeconds.Raw(), config.JSON.TimeoutSeconds.Valid(), config.TimeoutSeconds, &diags), FillRatePerMinute: flattenFillRatePerMinute(config.JSON.FillRatePerMinute.Raw(), config.JSON.FillRatePerMinute.Valid(), config.FillRatePerMinute, &diags), Viewport: flattenViewport(config.JSON.Viewport.Raw(), config.JSON.Viewport.Valid(), config.Viewport, &diags), + ChromePolicy: flattenChromePolicy(config.JSON.ChromePolicy.Raw(), config.JSON.ChromePolicy.Valid(), &diags), }, diags } @@ -384,6 +392,31 @@ func viewportAttributeTypes() map[string]attr.Type { } } +func flattenChromePolicy(raw string, valid bool, diags *diag.Diagnostics) types.String { + if raw == "" || !datasources.FieldPresent(raw) { + return types.StringNull() + } + if !valid { + datasources.AddInvalidResponseField(diags, "Browser Pool", "browser_pool_config.chrome_policy") + return types.StringNull() + } + + var policy map[string]any + if err := json.Unmarshal([]byte(raw), &policy); err != nil || policy == nil { + datasources.AddInvalidResponseField(diags, "Browser Pool", "browser_pool_config.chrome_policy") + return types.StringNull() + } + + var normalized bytes.Buffer + encoder := json.NewEncoder(&normalized) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(policy); err != nil { + datasources.AddInvalidResponseField(diags, "Browser Pool", "browser_pool_config.chrome_policy") + return types.StringNull() + } + return types.StringValue(strings.TrimSuffix(normalized.String(), "\n")) +} + func flattenResolvedProfileID(pool kernel.BrowserPool, diags *diag.Diagnostics) types.String { raw := pool.JSON.ProfileID.Raw() if raw != "" { diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go index 023b2d9..985c7e3 100644 --- a/internal/datasources/browserpool/datasource_test.go +++ b/internal/datasources/browserpool/datasource_test.go @@ -45,7 +45,7 @@ func TestDataSourceMetadataSchemaAndConfigure(t *testing.T) { var schema datasource.SchemaResponse ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema) - for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "extension_ids", "proxy_id", "headless", "kiosk_mode", "stealth", "start_url", "timeout_seconds", "fill_rate_per_minute", "viewport"} { + for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "extension_ids", "proxy_id", "headless", "kiosk_mode", "stealth", "start_url", "timeout_seconds", "fill_rate_per_minute", "viewport", "chrome_policy"} { if _, ok := schema.Schema.Attributes[name]; !ok { t.Fatalf("schema missing %s", name) } @@ -147,7 +147,8 @@ func TestReadSetsTerraformState(t *testing.T) { "start_url":"chrome://newtab", "timeout_seconds":10, "fill_rate_per_minute":0, - "viewport":{"width":1280,"height":800,"refresh_rate":60} + "viewport":{"width":1280,"height":800,"refresh_rate":60}, + "chrome_policy":{"RestoreOnStartup":4,"HomepageLocation":"https://example.com?x=1&y=2"} } }`), nil }, @@ -188,6 +189,51 @@ func TestReadSetsTerraformState(t *testing.T) { t.Fatalf("warmup state = %#v", state) } assertBrowserPoolViewport(t, state.Viewport, 1280, 800, types.Int64Value(60)) + if state.ChromePolicy.ValueString() != `{"HomepageLocation":"https://example.com?x=1&y=2","RestoreOnStartup":4}` { + t.Fatalf("chrome_policy = %q", state.ChromePolicy.ValueString()) + } +} + +func TestFlattenBrowserPoolChromePolicyNormalization(t *testing.T) { + t.Parallel() + + omitted, diags := flattenBrowserPool(*browserPoolFromJSON(`{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1}}`)) + if diags.HasError() { + t.Fatalf("unexpected omitted policy diagnostics: %v", diags) + } + if !omitted.ChromePolicy.IsNull() { + t.Fatalf("omitted chrome_policy = %v, want null", omitted.ChromePolicy) + } + explicitNull, diags := flattenBrowserPool(*browserPoolFromJSON(`{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"chrome_policy":null}}`)) + if diags.HasError() || !explicitNull.ChromePolicy.IsNull() { + t.Fatalf("explicit-null chrome_policy = %v, diagnostics = %v", explicitNull.ChromePolicy, diags) + } + + state, diags := flattenBrowserPool(*browserPoolFromJSON(`{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"chrome_policy":{"Tag":"","Number":1.0,"Nested":{"enabled":true}}}}`)) + if diags.HasError() { + t.Fatalf("unexpected policy diagnostics: %v", diags) + } + want := `{"Nested":{"enabled":true},"Number":1,"Tag":""}` + if state.ChromePolicy.ValueString() != want { + t.Fatalf("chrome_policy = %q, want %q", state.ChromePolicy.ValueString(), want) + } +} + +func TestFlattenBrowserPoolRejectsInvalidChromePolicy(t *testing.T) { + t.Parallel() + + for name, body := range map[string]string{ + "array": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"chrome_policy":[]}}`, + "string": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"chrome_policy":"policy"}}`, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, diags := flattenBrowserPool(*browserPoolFromJSON(body)) + if !diags.HasError() { + t.Fatal("expected diagnostics") + } + }) + } } func TestFlattenBrowserPoolViewportOptionalFields(t *testing.T) { @@ -540,6 +586,7 @@ func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value { "timeout_seconds": tftypes.Number, "fill_rate_per_minute": tftypes.Number, "viewport": viewportType, + "chrome_policy": tftypes.String, }}, map[string]tftypes.Value{ "id": id, @@ -556,6 +603,7 @@ func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value { "timeout_seconds": tftypes.NewValue(tftypes.Number, nil), "fill_rate_per_minute": tftypes.NewValue(tftypes.Number, nil), "viewport": tftypes.NewValue(viewportType, nil), + "chrome_policy": tftypes.NewValue(tftypes.String, nil), }, ) } From f868d78ce69898a0af54eb4b3d5d6a976a04b5aa Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 08:09:44 -0400 Subject: [PATCH 58/68] Test browser pool data source against Kernel Add opt-in acceptance coverage for ID and exact-name lookup, durable state flattening, no-drift planning, project overrides, and cleanup. Run it only from the manual acceptance matrix. --- .github/workflows/acceptance.yml | 3 + .../browserpool/datasource_acc_test.go | 120 ++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 internal/datasources/browserpool/datasource_acc_test.go diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 8cf7b0b..307fc50 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -21,6 +21,9 @@ jobs: - name: Browser pool package: ./internal/resources/browserpool project_id_required: true + - name: Browser pool data source + package: ./internal/datasources/browserpool + project_id_required: true - name: Project package: ./internal/resources/project project_id_required: false diff --git a/internal/datasources/browserpool/datasource_acc_test.go b/internal/datasources/browserpool/datasource_acc_test.go new file mode 100644 index 0000000..d2cf36e --- /dev/null +++ b/internal/datasources/browserpool/datasource_acc_test.go @@ -0,0 +1,120 @@ +package browserpool_test + +import ( + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/kernel/terraform-provider-kernel/internal/acctest" +) + +const browserPoolAcceptanceResourceName = "kernel_browser_pool.data_source_test" + +func TestAccBrowserPoolDataSourceByIDAndName(t *testing.T) { + name := acctest.UniqueName(t, "browser-pool-data") + defaultProjectID := os.Getenv(acctest.EnvProjectID) + projectID := os.Getenv(acctest.EnvAltProjectID) + if projectID == "" { + projectID = defaultProjectID + } + config := testAccBrowserPoolDataSourceConfig(name, projectID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + if defaultProjectID == "" { + t.Fatalf("%s must be set for the browser pool data source acceptance test", acctest.EnvProjectID) + } + }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + testAccCaptureBrowserPoolDataSourceID(t), + resource.TestCheckResourceAttrPair("data.kernel_browser_pool.by_id", "id", browserPoolAcceptanceResourceName, "id"), + resource.TestCheckResourceAttrPair("data.kernel_browser_pool.by_name", "id", browserPoolAcceptanceResourceName, "id"), + resource.TestCheckResourceAttrPair("data.kernel_browser_pool.by_id", "name", browserPoolAcceptanceResourceName, "name"), + resource.TestCheckResourceAttr("data.kernel_browser_pool.by_id", "project_id", projectID), + resource.TestCheckResourceAttr("data.kernel_browser_pool.by_name", "project_id", projectID), + testAccCheckBrowserPoolDataSourceState("data.kernel_browser_pool.by_id", name), + testAccCheckBrowserPoolDataSourceState("data.kernel_browser_pool.by_name", name), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} + +func testAccBrowserPoolDataSourceConfig(name, projectID string) string { + return acctest.ProviderConfig() + fmt.Sprintf(` +resource "kernel_browser_pool" "data_source_test" { + name = %[1]q + size = 1 + project_id = %[2]q + start_url = "chrome://newtab" + headless = true + kiosk_mode = false + stealth = false + timeout_seconds = 90 + fill_rate_per_minute = 0 + viewport = { + width = 1280 + height = 800 + refresh_rate = 60 + } + chrome_policy = jsonencode({ + HomepageLocation = "https://example.com" + RestoreOnStartup = 4 + }) +} + +data "kernel_browser_pool" "by_id" { + id = kernel_browser_pool.data_source_test.id + project_id = %[2]q +} + +data "kernel_browser_pool" "by_name" { + name = kernel_browser_pool.data_source_test.name + project_id = %[2]q +} +`, name, projectID) +} + +func testAccCaptureBrowserPoolDataSourceID(t *testing.T) resource.TestCheckFunc { + t.Helper() + + return func(state *terraform.State) error { + resourceState, ok := state.RootModule().Resources[browserPoolAcceptanceResourceName] + if !ok || resourceState.Primary == nil || resourceState.Primary.ID == "" { + return fmt.Errorf("missing ID for %s", browserPoolAcceptanceResourceName) + } + acctest.CleanupBrowserPool(t, resourceState.Primary.Attributes["project_id"], resourceState.Primary.ID) + return nil + } +} + +func testAccCheckBrowserPoolDataSourceState(resourceName, name string) resource.TestCheckFunc { + return resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "name", name), + resource.TestCheckResourceAttr(resourceName, "size", "1"), + resource.TestCheckResourceAttr(resourceName, "start_url", "chrome://newtab"), + resource.TestCheckResourceAttr(resourceName, "headless", "true"), + resource.TestCheckResourceAttr(resourceName, "kiosk_mode", "false"), + resource.TestCheckResourceAttr(resourceName, "stealth", "false"), + resource.TestCheckResourceAttr(resourceName, "timeout_seconds", "90"), + resource.TestCheckResourceAttr(resourceName, "fill_rate_per_minute", "0"), + resource.TestCheckResourceAttr(resourceName, "viewport.width", "1280"), + resource.TestCheckResourceAttr(resourceName, "viewport.height", "800"), + resource.TestCheckResourceAttr(resourceName, "viewport.refresh_rate", "60"), + resource.TestCheckResourceAttr(resourceName, "chrome_policy", `{"HomepageLocation":"https://example.com","RestoreOnStartup":4}`), + resource.TestCheckResourceAttr(resourceName, "extension_ids.#", "0"), + resource.TestCheckNoResourceAttr(resourceName, "profile_id"), + resource.TestCheckNoResourceAttr(resourceName, "proxy_id"), + ) +} From 069b7c30d9b36873cb9a95c8a0e2d95c90ee7fcd Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 08:27:47 -0400 Subject: [PATCH 59/68] Document proxy resource release gate Record the missing tagged proxy rename surface and the write-only password/import design required before Terraform can safely manage proxies. --- docs/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 0d3df97..6f629ba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -66,7 +66,7 @@ Core v1 data sources: Late or conditional v1 work: -- `kernel_proxy` resource, after write-only credential and import semantics are accepted +- `kernel_proxy` resource, after write-only credential/import semantics are accepted and a tagged SDK exposes in-place rename - masked `kernel_api_key` metadata lookup - `kernel_api_key` resource, only after plaintext-once, retry, rotation, import, and provider self-use semantics are accepted - project limits, only after their lifecycle is clearly separate from basic project management From b56f33ff596c406f978c6236ff7e1cf57156c372 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:20:52 -0400 Subject: [PATCH 60/68] Document deployment resource release gate Define the durable deployment lifecycle, partial-state recovery, write-only secret handling, import limits, and the API/SDK contracts required before implementation. Omit deployment force because the API currently forwards but does not consume it. --- docs/architecture.md | 19 ++++++++++++++++++- docs/release.md | 8 ++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 6f629ba..3ccd903 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,6 +73,17 @@ Late or conditional v1 work: Blocked candidates must remain unimplemented until the API and a tagged SDK expose the required durable contract. Provider code must not guess missing semantics, patch generated SDK code, or add a fallback HTTP client to bypass the durable client module. +`kernel_deployment` remains core v1 scope but is currently blocked until a +tagged SDK exposes source provenance, the API supports deterministic durable +readback, and the write-only environment/token plus metadata-only import design +is accepted. Its current status may be exposed only as computed inspection +metadata: it cannot be configured, drive a diff, or be populated from an event +stream. The `kernel_deployment` data source is limited to fields returned by a +durable deployment GET; app name, version, and actions remain the responsibility +of the exact-lookup `kernel_app` data source until deployment reads expose them +directly. The resource omits `force` until the API implements and tests the +documented overwrite behavior. `kernel_app` remains lookup-only. + Terraform schema and model code generation remains deferred. The current tool produced valid output but did not reduce code or review complexity, and broad OpenAPI-driven generation would further weaken the durable allowlist. The evaluation evidence and reconsideration criteria are defined in [Terraform Framework Code Generation Decision](codegen.md). @@ -285,7 +296,13 @@ Data sources must not create, mutate, acquire, release, invoke, or recover Kerne Every resource should import by canonical ID where the API can reconstruct durable state. Project-scoped resources may also accept a documented project-qualified form when needed to resolve a non-default project. -Read after import must flatten durable API state into Terraform state without introducing runtime fields. If the API cannot return create-only configuration or sensitive values, the resource must document metadata-only import or remain deferred. The provider returns a clear diagnostic instead of guessing. +Read after import must flatten durable API state into Terraform state without +introducing unmanaged runtime fields. Explicitly approved computed inspection +metadata, such as deployment status, may be populated from a durable GET when +it cannot be configured or drive diffs; event and log streams are never import +read paths. If the API cannot return create-only configuration or sensitive +values, the resource must document metadata-only import or remain deferred. The +provider returns a clear diagnostic instead of guessing. ## Testing Strategy diff --git a/docs/release.md b/docs/release.md index 276aff3..b86ce06 100644 --- a/docs/release.md +++ b/docs/release.md @@ -64,11 +64,15 @@ Do not replace or mutate assets for a published version. If an asset, checksum, - Provider `api_key` remains sensitive. - `internal/kernelclient` exposes durable methods only; no acquire, release, flush, force-release, screenshots, logs, live view, or app invocation. -- Every resource state contains durable desired configuration only. +- Every resource state contains durable desired configuration plus only + explicitly approved, computed inspection metadata that cannot be configured + or drive diffs; no resource state is populated from event or log streams. - Every data source is lookup-only and side-effect free. - Project lifecycle uses organization-scoped endpoints and documents the permissions required for create, archive, and delete; if project limits are included later, their permissions receive a separate review. - If API key management is included, reads expose masked metadata only; plaintext-once values are sensitive, import cannot recover plaintext, and rotation/self-use semantics have explicit safety review. -- Proxy credentials, deployment environment variables, source tokens, and other secret inputs are sensitive and preserve configured state when API reads return masked values. +- Proxy credentials, deployment environment variables, source tokens, and + other secret inputs are sensitive write-only values that never enter state; + only explicit replacement keepers and readable masked metadata persist. - Browser pool read state does not include runtime counters, standby state, leased-browser state, runtime URLs, screenshots, logs, or live-view fields. - Delete uses `force=false`; Terraform must not terminate leased browsers as cleanup. - Each resource imports by canonical ID where the API can reconstruct durable state; metadata-only or unsupported imports are documented rather than guessed. 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 61/68] 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) } From 7c330ea3f311e48a5cf1a35087f768b4f4cf5b77 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:56:04 -0400 Subject: [PATCH 62/68] Define safe API key state boundaries --- docs/api-key-state.md | 212 ++++++++++++++++++++++++++++++++++++++++++ docs/architecture.md | 6 ++ 2 files changed, 218 insertions(+) create mode 100644 docs/api-key-state.md diff --git a/docs/api-key-state.md b/docs/api-key-state.md new file mode 100644 index 0000000..86c8cae --- /dev/null +++ b/docs/api-key-state.md @@ -0,0 +1,212 @@ +# API Key Terraform State Design + +## Decision + +The masked `kernel_api_key` data source is safe to implement after a tagged +Kernel Go SDK exposes the API's exact-name list filter. + +The `kernel_api_key` resource is deferred. Its Terraform state shape is +accepted below, but Create and Rotate must not ship until the API provides +replayable idempotency and lets the provider identify or reject rotation of the +credential authenticating the current request. The API must also expose that +credential's effective project scope so provider-default fallback can settle +predictably into Terraform state. + +## First Principles + +An API key is durable infrastructure, but its plaintext is not durable API +metadata. Kernel returns plaintext exactly once after Create or Rotate. Later +reads return a masked key and stable metadata. + +Terraform can manage that lifecycle only when: + +- retrying an uncertain Create or Rotate returns the same key and plaintext; +- refresh never replaces plaintext state with a masked value; +- import does not pretend it can recover plaintext; +- rotation is an explicit configuration change; +- the provider cannot rotate away the credential it is using unknowingly; and +- project scope follows the provider's documented precedence and settles into + durable state. + +## Current Kernel Contract + +Kernel Go SDK v0.76.0, the latest tagged release at the time of this decision, +exposes Create, Get, Update, List, Delete, and Rotate. The durable API contract +has these properties: + +- Create returns a new canonical ID, plaintext key, masked key, name, creator, + timestamps, and optional project metadata. +- Get and List return masked metadata only. +- Update changes only the name. +- Delete soft-deletes a key and rejects deletion of the current key with the + coded `cannot_delete_current_key` error. +- Rotate creates a new canonical key, returns its plaintext once, and schedules + the old key to expire at the earlier of its existing expiration and the grace + deadline. Rotation never extends an old key's life. +- Rotation does not soft-delete the old key. Its expired record remains in + ordinary active listings because that status currently means non-deleted. +- Names are not unique. +- Project-scoped callers can manage keys only in their own project and cannot + create or access organization-wide keys. +- A deleted key is hidden from ordinary Get and a repeated Delete returns + `not_found`. + +The API supports an exact-name List filter. It is generated in the pending Go +SDK v0.77.0 release, so the provider must wait for that tag rather than adding +direct HTTP or a second client. + +## Future Resource State + +The resource should use this state model once the API blockers are resolved: + +| Attribute | Terraform behavior | +| --- | --- | +| `id` | Computed canonical API-key ID. Rotate replaces it with the new key's ID. | +| `name` | Required durable name. Updated in place. Names are labels, not identity. | +| `project_id` | Optional scope override. It defaults to the provider-level `project_id`, then the authenticated key's project binding. Null after resolution creates an organization-wide key. Changing resolved scope replaces the key. | +| `days_to_expire` | Optional lifetime from 1 through 3650 days on Create. When omitted on a new key, the key does not expire. A later finite change is valid only with a simultaneous `rotation_keeper` change; because v1 uses the seven-day default grace, the changed lifetime must be 8 through 3650 days. Removing a known finite value is rejected because Rotate cannot produce a never-expiring replacement. After metadata-only import, omission means the original relative lifetime is unmanaged. | +| `rotation_keeper` | Optional opaque string. Only a change after Create triggers Rotate. Its value is provider state, not remote metadata. | +| `key` | Computed, sensitive plaintext returned by Create or Rotate. Read preserves the prior value; import leaves it null. | +| `masked_key` | Computed durable masked value from Get. It never substitutes for `key`. | +| `created_at` | Computed creation timestamp. | +| `expires_at` | Computed nullable expiration timestamp. | +| `created_by` | Computed creator metadata. | +| `project_name` | Computed nullable project metadata. | + +`key` being sensitive controls display, not storage encryption. The plaintext +remains in Terraform state after Create or Rotate so downstream configuration +can use it. Users must use an encrypted remote backend with restricted state +access. Removing the value on the next Read would make dependent configuration +unstable and would not erase it from state history. + +`days_to_expire` records create-time intent; Kernel reads expose the resulting +absolute `expires_at`, not the original relative input. Import therefore cannot +reconstruct that input and must not guess it from timestamp subtraction. + +Project resolution is resource override, provider default, authenticated-key +binding, then organization-wide when no project applies. State stores the +resolved project ID. The current API requires a project-scoped caller to send +`project_id` but does not expose that caller's canonical scope to this provider. +Until current-key metadata closes that gap, callers must configure either the +resource or provider project explicitly. + +## Lifecycle + +Create sends explicit `name`, resolved `project_id`, and `days_to_expire` +values. It disables automatic mutation retries. On success it stores the +canonical ID and plaintext before any later operation can fail. + +Read uses masked Get metadata. It refreshes readable fields while preserving +the prior plaintext, create-time lifetime, and rotation keeper. A coded +`not_found` response removes the resource from state. Expiration alone does not +remove the durable key record. + +Update patches a changed name before Rotate so the replacement key copies the +new name. A changed `rotation_keeper` then calls Rotate and stores the returned +new ID and plaintext immediately. If `days_to_expire` changes in the same plan, +Rotate uses that new lifetime; changing the lifetime without changing the +keeper is a plan error. The old key is no longer owned by the Terraform +resource. Kernel expires it by the grace deadline, sooner when its existing +expiration is earlier, and immediately when it is already expired. Kernel +retains the non-deleted historical record; deleting the resource later deletes +only the replacement key. Rotation uses the API's default grace and otherwise +preserves the old key's original lifetime for the replacement. Custom grace +controls are deferred until a concrete Terraform workflow requires them. + +## Null, Empty, And Defaults + +- `name` must contain 1 through 255 UTF-8 bytes and must equal its trimmed value. + Empty, whitespace-only, and leading/trailing-whitespace values are invalid so + API normalization cannot create a perpetual diff. +- `project_id` may be omitted for scope resolution, but an explicit empty string + is invalid. A resolved null value means organization-wide. +- `days_to_expire` omitted or null means no expiry for a newly created key. On + Rotate, omission asks Kernel to preserve the old key's original lifetime. A + changed rotation lifetime must be at least eight days so the replacement + outlives the seven-day default grace window; this does not restrict a new + key's initial 1-3650-day lifetime. Removing a known finite value is a plan + error because the API treats rotation null as preserve, not never-expiring. + Moving to no expiry requires creating a separate key and migrating consumers + before removing the old resource. +- `rotation_keeper` may be omitted. When configured it must be non-empty. Create + records its initial value without an extra Rotate because Create already + issued fresh plaintext. Import leaves it null; setting it after import is an + explicit first rotation, and every later value change rotates again. +- The API's omitted rotation grace defaults to seven days. The v1 resource does + not expose a second grace control. +- Nullable read fields remain Terraform null. Zero times and empty strings must + not be used as substitutes for API null values. + +Delete treats `not_found` as success. The coded self-delete response is a +diagnostic and retains the resource ID so a different provider credential can +retry. Terraform must not hide that failure or remove state. + +Import accepts only the canonical API-key ID. Read then imports masked metadata. +The plaintext `key`, original `days_to_expire`, and prior rotation keeper remain +unknown or unset. Import documentation must call this metadata-only import. + +## Self-Use Rule + +A provider instance must be authenticated by a separate administrative key +from every `kernel_api_key` resource it manages. Provider configuration cannot +depend safely on a key created by that same provider instance. + +The API already prevents self-delete, but Rotate currently allows the current +key to rotate and schedules it to expire. The provider cannot derive the +authenticated key's canonical ID from its plaintext configuration or masked +metadata. Documentation alone is not a sufficient guard for an operation that +can make the next Terraform run unable to authenticate. + +Before the resource ships, Kernel must expose request-context metadata that +identifies the current key and its canonical project scope, or reject +self-rotation with a stable coded error and separately expose the effective +project binding. The provider will use that signal as an internal safety check; +current-key identity does not belong in durable Terraform state. + +## Idempotency Gate + +Create and Rotate have no idempotency key. A connection failure after the API +commits but before Terraform receives the response loses the only plaintext +response. Create can leave an untracked credential. Rotate can additionally +schedule the old credential to expire, and a retry can create another key. + +Before the resource ships, both operations must accept an idempotency key and +replay the same canonical ID and plaintext response for the same request. It is +not enough to deduplicate by name because names are intentionally non-unique. +The implementation design must prove that its request token survives a failed +apply and process restart; API support without a stable Terraform-side token is +not sufficient. Generic SDK mutation retries remain disabled outside that +replay contract. + +## Masked Data Source + +The data source is independent of plaintext lifecycle and may ship first. It +should accept exactly one of canonical `id` or exact `name`, scan all pages for +name lookup, deduplicate by ID, and diagnose zero or multiple non-deleted +matches. Expired-but-not-deleted keys remain visible because they are durable +records under the current API status definition. The API's name filter follows +the production database's case- and accent-insensitive collation; the provider +must post-filter returned names byte-for-byte so Terraform's exact selector has +stable semantics. Rotation retains an old row with the same name, so name +lookup becomes ambiguous after rotation and callers must use the canonical ID. +It may expose ID, name, masked key, creator, creation/expiration timestamps, and +nullable project metadata. It must never expose plaintext, deleted audit rows, +or provider-authentication identity. + +## Unblocking Checklist + +- Tag an SDK release containing the exact API-key name filter. +- Add replayable idempotency for API-key Create and Rotate. +- Add a stable current-key/project-scope signal or coded self-rotation + rejection plus effective-scope metadata. +- Verify nullable SDK response fields against API `null` responses. +- Acceptance-test ASCII and multibyte name boundaries against the API's + byte-count validation. +- Acceptance-test a short initial lifetime, unchanged short-lifetime rotation, + rejection of a changed 1-7-day rotation lifetime, and rejection of a known + finite-to-null transition. +- Acceptance-test Create, Read, rename, explicit rotation, metadata-only + import, first rotation after import, project scope, retained rotation history, + near-expiry and already-expired rotation, not-found deletion, and self-use + diagnostics. +- Repeat the sensitive-state and release review before registering the resource. diff --git a/docs/architecture.md b/docs/architecture.md index 3ccd903..359f6cc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,6 +73,12 @@ Late or conditional v1 work: Blocked candidates must remain unimplemented until the API and a tagged SDK expose the required durable contract. Provider code must not guess missing semantics, patch generated SDK code, or add a fallback HTTP client to bypass the durable client module. +The accepted plaintext, import, rotation, and self-use model for API keys is +defined in [API Key Terraform State Design](api-key-state.md). The masked data +source may proceed after the exact-name SDK surface is tagged. The resource +remains blocked on replayable Create/Rotate idempotency and a current-key +rotation guard with effective authenticated project-scope metadata. + `kernel_deployment` remains core v1 scope but is currently blocked until a tagged SDK exposes source provenance, the API supports deterministic durable readback, and the write-only environment/token plus metadata-only import design From df516afb6f8ee6148e2cf5a305a6a280fc0b82b9 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:27:48 -0400 Subject: [PATCH 63/68] Define the v1 acceptance release gate --- README.md | 11 ++---- docs/acceptance.md | 90 ++++++++++++++++++++++++++++++++++++++++++++ docs/architecture.md | 3 ++ docs/release.md | 7 +--- 4 files changed, 98 insertions(+), 13 deletions(-) create mode 100644 docs/acceptance.md diff --git a/README.md b/README.md index f4d321c..7fe3dc9 100644 --- a/README.md +++ b/README.md @@ -152,19 +152,14 @@ Browser-pool acceptance tests additionally require: export KERNEL_PROJECT_ID="..." ``` -Run the durable resource acceptance tests: - -```sh -go test -count=1 -timeout=30m -v ./internal/resources/browserpool -run TestAcc -go test -count=1 -timeout=30m -v ./internal/resources/extension -run TestAcc -go test -count=1 -timeout=30m -v ./internal/resources/project -run TestAcc -``` - The tests create uniquely named durable resources and register independent cleanup. Extension acceptance creates a small temporary Manifest V3 archive and tests checksum-driven replacement. Browser-pool deletion remains `force=false`. The tests do not acquire browsers or perform runtime recovery. +Use the commands in the [v1 acceptance matrix](docs/acceptance.md), which is the +single source for current coverage, tag blockers, and the release-run record. + ## Architecture See [docs/architecture.md](docs/architecture.md) for package layout, Terraform semantics, testing strategy, and release planning. diff --git a/docs/acceptance.md b/docs/acceptance.md new file mode 100644 index 0000000..03ef6e9 --- /dev/null +++ b/docs/acceptance.md @@ -0,0 +1,90 @@ +# v1 Acceptance Matrix + +This document is the live-API release gate for the first public v1. Unit tests +remain the fast default; acceptance tests run only through explicit local opt-in +or the manual GitHub Actions workflow. + +## Gate Rules + +- Set both `TF_ACC=1` and `KERNEL_ACC=1`. +- Use unique `kernel-tf-*` names for every created fixture. +- Register cleanup as soon as a canonical ID exists. +- Verify deletion with a coded `not_found` response where the API supports it. +- Never acquire, release, flush, invoke, force-release, or recover runtime state. +- Keep live tests out of pull-request CI. +- Record one complete green matrix run against the release commit before tagging. +- Every registered v1 surface must have a live acceptance test and pass against + the release commit. A fixture blocker is a v1 tag blocker, not a release-note + exception. An unregistered deferred resource is not part of the live matrix. + +## Environment + +The current workflow uses: + +```sh +export TF_ACC=1 +export KERNEL_ACC=1 +export KERNEL_API_KEY=... +export KERNEL_PROJECT_ID=... +export KERNEL_ALT_PROJECT_ID=... # optional second project +export KERNEL_BASE_URL=... # optional non-production API +``` + +Future fixture-backed data-source tests may add narrowly named variables only +when the provider cannot create and clean up the fixture through a durable SDK +operation. Secrets must remain GitHub Actions secrets and must not be printed. + +## Current Matrix + +| Surface | Repository test status | Acceptance scenario | Required follow-up | +| --- | --- | --- | --- | +| `kernel_project` resource | Test present | Create, rename, no-drift plan, import, delete, and 404 verification. | None. | +| `kernel_browser_pool` resource | Test present | Create, durable update with stable ID, no-drift plan, bare and project-qualified import paths, explicit project scope, non-force delete, and 404 verification. | Keep leased-browser conflict behavior in unit tests; acceptance must not create runtime leases. | +| `kernel_extension` resource | Test present | Upload, checksum state, no-drift plan, metadata-only import, content replacement with new ID, old-ID disappearance, delete, and 404 verification. | Add project-qualified import to live coverage before the v1 tag. | +| `kernel_browser_pool` data source | Test present; tag blocker | A uniquely created pool is read by canonical ID and byte-exact name, including normalized durable configuration and a no-drift plan. | Add post-cleanup Get verification that requires coded `not_found`; the current cleanup sends Delete but does not prove absence. | +| `kernel_project` data source | Test missing; tag blocker | Unit and fake-client tests only. | Use a uniquely created `kernel_project` fixture and verify current/ID/name lookup plus no drift. | +| `kernel_extension` data source | Test missing; tag blocker | Unit and fake-client tests only. | Use a uniquely uploaded `kernel_extension` fixture and verify ID/name metadata lookup plus no drift. | +| `kernel_profile` data source | Test missing; tag blocker | Unit and fake-client tests only. | Add durable SDK fixture create/delete helpers, then verify ID/name lookup and cleanup. Do not model runtime-written profile contents. | +| `kernel_proxy` data source | Test missing; tag blocker | Unit and fake-client tests only. | Add a durable, non-secret-leaking proxy fixture strategy and verify ID/name lookup, masked metadata, and cleanup. | +| `kernel_app` data source | Fixture blocked; tag blocker | Unit, SDK transport, pagination, ambiguity, project-scope, and Framework state tests only. | Provide a release-owned running deployment fixture or a deterministic durable deployment setup. Verify exact app/version lookup without invocation and without exposing env values. | +| `kernel_api_key` data source | Deferred; unregistered | No provider surface yet. | Wait for a tagged SDK with exact-name filtering, then add masked ID/name lookup acceptance. | +| Profile, proxy, deployment, and API-key resources | Deferred; unregistered | No provider surfaces yet. | Enter the matrix only after their documented API/SDK/state blockers are resolved and implementation lands. | + +"Test present" describes code in the repository; it does not claim a run +against the release commit. The release record below supplies that evidence. + +## Current Commands + +Run the four existing 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/extension -run TestAcc +go test -count=1 -timeout=30m -v ./internal/datasources/browserpool -run TestAcc +``` + +The manual `Acceptance` workflow runs the same packages as separate matrix jobs +with `fail-fast: false`. Add a package to that workflow in the same PR that adds +its first live test. + +## Release Record + +Record this information in the v1 release PR or release issue, not in this +repository with secrets: + +```text +Commit: +Workflow run URL: +API environment: +Started at: +Completed at: +Package results: +Interrupted or timed-out jobs: +Leaked-resource audit completed: +Unregistered deferred surfaces: +``` + +An interrupted process can bypass `t.Cleanup`. Follow the ordered cleanup and +404 verification procedure in [Release And Security Checklist](release.md) +before rerunning or tagging. diff --git a/docs/architecture.md b/docs/architecture.md index 359f6cc..0fe58c1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -335,6 +335,9 @@ Acceptance tests must: - avoid browser/session runtime operations - exercise import and real delete behavior for each resource +The current live coverage and remaining release gates are tracked in the +[v1 Acceptance Matrix](acceptance.md). + ## PR Slicing PRs must be small, coherent, and shippable. No PR should rely on hidden follow-up work to keep the repo healthy. diff --git a/docs/release.md b/docs/release.md index b86ce06..73cbd7b 100644 --- a/docs/release.md +++ b/docs/release.md @@ -12,11 +12,8 @@ Use this checklist before publishing a Kernel Terraform provider version. - Run `terraform fmt -check -recursive examples`. - Run `go test -short -timeout=2m ./...`. - Run `go vet ./...`. -- Run the complete opt-in acceptance matrix for every v1 resource and data source with real credentials before the first public release. - - Browser pools: `TF_ACC=1 KERNEL_ACC=1 KERNEL_API_KEY=... KERNEL_PROJECT_ID=... go test -count=1 -timeout=30m -v ./internal/resources/browserpool -run TestAcc`. - - Extensions: `TF_ACC=1 KERNEL_ACC=1 KERNEL_API_KEY=... KERNEL_PROJECT_ID=... go test -count=1 -timeout=30m -v ./internal/resources/extension -run TestAcc`. - - Projects: `TF_ACC=1 KERNEL_ACC=1 KERNEL_API_KEY=... go test -count=1 -timeout=30m -v ./internal/resources/project -run TestAcc`. - - The manual `Acceptance` workflow runs all three packages in parallel; expand its matrix as v1 resources land and keep live tests out of normal PR CI. +- Run the complete [v1 acceptance matrix](acceptance.md) for every registered v1 resource and data source with real credentials before the first public release. +- Use the commands and status table in `docs/acceptance.md` as the single source of truth. The manual `Acceptance` workflow runs all current packages in parallel; add each new package in the same PR as its first live test and keep live tests out of normal PR 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, and extensions named `kernel-tf-*` that were created during the failed workflow run. 2. Delete leaked browser pools first 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. From 3910e5c1d7eb48d8432fdb735c1097ba669efa9d Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:41:48 -0400 Subject: [PATCH 64/68] Verify browser pool data source cleanup --- docs/acceptance.md | 2 +- .../browserpool/datasource_acc_test.go | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/acceptance.md b/docs/acceptance.md index 03ef6e9..2ab594c 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -41,7 +41,7 @@ operation. Secrets must remain GitHub Actions secrets and must not be printed. | `kernel_project` resource | Test present | Create, rename, no-drift plan, import, delete, and 404 verification. | None. | | `kernel_browser_pool` resource | Test present | Create, durable update with stable ID, no-drift plan, bare and project-qualified import paths, explicit project scope, non-force delete, and 404 verification. | Keep leased-browser conflict behavior in unit tests; acceptance must not create runtime leases. | | `kernel_extension` resource | Test present | Upload, checksum state, no-drift plan, metadata-only import, content replacement with new ID, old-ID disappearance, delete, and 404 verification. | Add project-qualified import to live coverage before the v1 tag. | -| `kernel_browser_pool` data source | Test present; tag blocker | A uniquely created pool is read by canonical ID and byte-exact name, including normalized durable configuration and a no-drift plan. | Add post-cleanup Get verification that requires coded `not_found`; the current cleanup sends Delete but does not prove absence. | +| `kernel_browser_pool` data source | Test present | A uniquely created pool is read by canonical ID and byte-exact name, including normalized durable configuration, a no-drift plan, and post-destroy coded `not_found` verification. | None. | | `kernel_project` data source | Test missing; tag blocker | Unit and fake-client tests only. | Use a uniquely created `kernel_project` fixture and verify current/ID/name lookup plus no drift. | | `kernel_extension` data source | Test missing; tag blocker | Unit and fake-client tests only. | Use a uniquely uploaded `kernel_extension` fixture and verify ID/name metadata lookup plus no drift. | | `kernel_profile` data source | Test missing; tag blocker | Unit and fake-client tests only. | Add durable SDK fixture create/delete helpers, then verify ID/name lookup and cleanup. Do not model runtime-written profile contents. | diff --git a/internal/datasources/browserpool/datasource_acc_test.go b/internal/datasources/browserpool/datasource_acc_test.go index d2cf36e..2d048d3 100644 --- a/internal/datasources/browserpool/datasource_acc_test.go +++ b/internal/datasources/browserpool/datasource_acc_test.go @@ -1,9 +1,11 @@ package browserpool_test import ( + "context" "fmt" "os" "testing" + "time" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" @@ -29,6 +31,7 @@ func TestAccBrowserPoolDataSourceByIDAndName(t *testing.T) { } }, ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories(), + CheckDestroy: testAccCheckBrowserPoolDataSourceDestroyed(), Steps: []resource.TestStep{ { Config: config, @@ -51,6 +54,30 @@ func TestAccBrowserPoolDataSourceByIDAndName(t *testing.T) { }) } +func testAccCheckBrowserPoolDataSourceDestroyed() 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_browser_pool" || resourceState.Primary == nil || resourceState.Primary.ID == "" { + continue + } + + _, err := client.GetBrowserPool(ctx, resourceState.Primary.Attributes["project_id"], resourceState.Primary.ID) + if acctest.IsNotFound(err) { + continue + } + if err != nil { + return fmt.Errorf("read Kernel browser pool %s after destroy: %w", resourceState.Primary.ID, err) + } + return fmt.Errorf("Kernel browser pool %s still exists after destroy", resourceState.Primary.ID) + } + return nil + } +} + func testAccBrowserPoolDataSourceConfig(name, projectID string) string { return acctest.ProviderConfig() + fmt.Sprintf(` resource "kernel_browser_pool" "data_source_test" { From afaed74c62239162f4366c5beb78fbadd33a6672 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:54:18 -0400 Subject: [PATCH 65/68] Require coded not-found in destroy check --- internal/datasources/browserpool/datasource_acc_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/datasources/browserpool/datasource_acc_test.go b/internal/datasources/browserpool/datasource_acc_test.go index 2d048d3..a13eab4 100644 --- a/internal/datasources/browserpool/datasource_acc_test.go +++ b/internal/datasources/browserpool/datasource_acc_test.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/kernel/terraform-provider-kernel/internal/acctest" + "github.com/kernel/terraform-provider-kernel/internal/projectscope" ) const browserPoolAcceptanceResourceName = "kernel_browser_pool.data_source_test" @@ -66,7 +67,7 @@ func testAccCheckBrowserPoolDataSourceDestroyed() resource.TestCheckFunc { } _, err := client.GetBrowserPool(ctx, resourceState.Primary.Attributes["project_id"], resourceState.Primary.ID) - if acctest.IsNotFound(err) { + if projectscope.IsNotFound(err) { continue } if err != nil { From b173cc2b61667ce757c4277ad05e0f14f21d185c Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:10:46 -0400 Subject: [PATCH 66/68] Test project data source against Kernel --- .github/workflows/acceptance.yml | 3 + docs/acceptance.md | 5 +- .../project/datasource_acc_test.go | 104 ++++++++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 internal/datasources/project/datasource_acc_test.go diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 307fc50..720bffc 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: Project data source + package: ./internal/datasources/project + project_id_required: true - name: Extension package: ./internal/resources/extension project_id_required: true diff --git a/docs/acceptance.md b/docs/acceptance.md index 2ab594c..ed23d81 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -42,7 +42,7 @@ operation. Secrets must remain GitHub Actions secrets and must not be printed. | `kernel_browser_pool` resource | Test present | Create, durable update with stable ID, no-drift plan, bare and project-qualified import paths, explicit project scope, non-force delete, and 404 verification. | Keep leased-browser conflict behavior in unit tests; acceptance must not create runtime leases. | | `kernel_extension` resource | Test present | Upload, checksum state, no-drift plan, metadata-only import, content replacement with new ID, old-ID disappearance, delete, and 404 verification. | Add project-qualified import to live coverage before the v1 tag. | | `kernel_browser_pool` data source | Test present | A uniquely created pool is read by canonical ID and byte-exact name, including normalized durable configuration, a no-drift plan, and post-destroy coded `not_found` verification. | None. | -| `kernel_project` data source | Test missing; tag blocker | Unit and fake-client tests only. | Use a uniquely created `kernel_project` fixture and verify current/ID/name lookup plus no drift. | +| `kernel_project` data source | Test present | A uniquely created project is read by canonical ID and exact name; the provider default resolves the configured project; metadata, no-drift planning, and post-destroy coded `not_found` are verified. | None. | | `kernel_extension` data source | Test missing; tag blocker | Unit and fake-client tests only. | Use a uniquely uploaded `kernel_extension` fixture and verify ID/name metadata lookup plus no drift. | | `kernel_profile` data source | Test missing; tag blocker | Unit and fake-client tests only. | Add durable SDK fixture create/delete helpers, then verify ID/name lookup and cleanup. Do not model runtime-written profile contents. | | `kernel_proxy` data source | Test missing; tag blocker | Unit and fake-client tests only. | Add a durable, non-secret-leaking proxy fixture strategy and verify ID/name lookup, masked metadata, and cleanup. | @@ -55,10 +55,11 @@ against the release commit. The release record below supplies that evidence. ## Current Commands -Run the four existing packages independently for fast failure isolation: +Run the five existing 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/datasources/project -run TestAcc go test -count=1 -timeout=30m -v ./internal/resources/browserpool -run TestAcc go test -count=1 -timeout=30m -v ./internal/resources/extension -run TestAcc go test -count=1 -timeout=30m -v ./internal/datasources/browserpool -run TestAcc diff --git a/internal/datasources/project/datasource_acc_test.go b/internal/datasources/project/datasource_acc_test.go new file mode 100644 index 0000000..930d93d --- /dev/null +++ b/internal/datasources/project/datasource_acc_test.go @@ -0,0 +1,104 @@ +package project_test + +import ( + "context" + "fmt" + "os" + "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" + "github.com/kernel/terraform-provider-kernel/internal/projectscope" +) + +const projectDataSourceFixtureName = "kernel_project.data_source_test" + +func TestAccProjectDataSourceByIDNameAndProviderDefault(t *testing.T) { + name := acctest.UniqueName(t, "project-data") + config := testAccProjectDataSourceConfig(name) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + if os.Getenv(acctest.EnvProjectID) == "" { + t.Fatalf("%s must be set for the project data source acceptance test", acctest.EnvProjectID) + } + }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories(), + CheckDestroy: testAccCheckProjectDataSourceFixtureDestroyed(), + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + testAccCaptureProjectDataSourceFixture(t), + resource.TestCheckResourceAttrPair("data.kernel_project.by_id", "id", projectDataSourceFixtureName, "id"), + resource.TestCheckResourceAttrPair("data.kernel_project.by_id", "name", projectDataSourceFixtureName, "name"), + resource.TestCheckResourceAttrPair("data.kernel_project.by_name", "id", projectDataSourceFixtureName, "id"), + resource.TestCheckResourceAttrPair("data.kernel_project.by_name", "name", projectDataSourceFixtureName, "name"), + resource.TestCheckResourceAttr("data.kernel_project.current", "id", os.Getenv(acctest.EnvProjectID)), + resource.TestCheckResourceAttrSet("data.kernel_project.by_id", "created_at"), + resource.TestCheckResourceAttrSet("data.kernel_project.by_id", "updated_at"), + resource.TestCheckResourceAttrSet("data.kernel_project.by_id", "status"), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} + +func testAccProjectDataSourceConfig(name string) string { + return acctest.ProviderConfig() + fmt.Sprintf(` +resource "kernel_project" "data_source_test" { + name = %q +} + +data "kernel_project" "by_id" { + id = kernel_project.data_source_test.id +} + +data "kernel_project" "by_name" { + name = kernel_project.data_source_test.name +} + +data "kernel_project" "current" {} +`, name) +} + +func testAccCaptureProjectDataSourceFixture(t *testing.T) resource.TestCheckFunc { + t.Helper() + + return func(state *terraform.State) error { + resourceState, ok := state.RootModule().Resources[projectDataSourceFixtureName] + if !ok || resourceState.Primary == nil || resourceState.Primary.ID == "" { + return fmt.Errorf("missing ID for %s", projectDataSourceFixtureName) + } + acctest.CleanupProject(t, resourceState.Primary.ID) + return nil + } +} + +func testAccCheckProjectDataSourceFixtureDestroyed() resource.TestCheckFunc { + return func(state *terraform.State) error { + resourceState, ok := state.RootModule().Resources[projectDataSourceFixtureName] + if !ok || resourceState.Primary == nil || resourceState.Primary.ID == "" { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + _, err := acctest.ClientFromEnv().GetProject(ctx, resourceState.Primary.ID) + if projectscope.IsNotFound(err) { + return nil + } + if err != nil { + return fmt.Errorf("read Kernel project %s after destroy: %w", resourceState.Primary.ID, err) + } + return fmt.Errorf("Kernel project %s still exists after destroy", resourceState.Primary.ID) + } +} From ebe2c13d1ffe8e29768e2790959808b76cb4e1d5 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:29:18 -0400 Subject: [PATCH 67/68] Test extension data source against Kernel --- .github/workflows/acceptance.yml | 3 + docs/acceptance.md | 5 +- internal/acctest/extension.go | 51 ++++++++ .../extension/datasource_acc_test.go | 109 ++++++++++++++++++ .../resources/extension/resource_acc_test.go | 48 +------- 5 files changed, 168 insertions(+), 48 deletions(-) create mode 100644 internal/acctest/extension.go create mode 100644 internal/datasources/extension/datasource_acc_test.go diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 720bffc..794bae1 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -33,6 +33,9 @@ jobs: - name: Extension package: ./internal/resources/extension project_id_required: true + - name: Extension data source + package: ./internal/datasources/extension + project_id_required: true # Keep the job timeout above the go test timeout so Go can report the test # timeout before the runner stops the job. Either hard timeout can bypass # t.Cleanup, so release operators still inspect for leaked test resources. diff --git a/docs/acceptance.md b/docs/acceptance.md index ed23d81..0585208 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -43,7 +43,7 @@ operation. Secrets must remain GitHub Actions secrets and must not be printed. | `kernel_extension` resource | Test present | Upload, checksum state, no-drift plan, metadata-only import, content replacement with new ID, old-ID disappearance, delete, and 404 verification. | Add project-qualified import to live coverage before the v1 tag. | | `kernel_browser_pool` data source | Test present | A uniquely created pool is read by canonical ID and byte-exact name, including normalized durable configuration, a no-drift plan, and post-destroy coded `not_found` verification. | None. | | `kernel_project` data source | Test present | A uniquely created project is read by canonical ID and exact name; the provider default resolves the configured project; metadata, no-drift planning, and post-destroy coded `not_found` are verified. | None. | -| `kernel_extension` data source | Test missing; tag blocker | Unit and fake-client tests only. | Use a uniquely uploaded `kernel_extension` fixture and verify ID/name metadata lookup plus no drift. | +| `kernel_extension` data source | Test present | A uniquely uploaded extension is read by canonical ID and exact name through explicit and provider-default project scope; metadata, no-drift planning, and post-destroy coded `not_found` are verified. | None. | | `kernel_profile` data source | Test missing; tag blocker | Unit and fake-client tests only. | Add durable SDK fixture create/delete helpers, then verify ID/name lookup and cleanup. Do not model runtime-written profile contents. | | `kernel_proxy` data source | Test missing; tag blocker | Unit and fake-client tests only. | Add a durable, non-secret-leaking proxy fixture strategy and verify ID/name lookup, masked metadata, and cleanup. | | `kernel_app` data source | Fixture blocked; tag blocker | Unit, SDK transport, pagination, ambiguity, project-scope, and Framework state tests only. | Provide a release-owned running deployment fixture or a deterministic durable deployment setup. Verify exact app/version lookup without invocation and without exposing env values. | @@ -55,13 +55,14 @@ against the release commit. The release record below supplies that evidence. ## Current Commands -Run the five existing packages independently for fast failure isolation: +Run the six existing 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/datasources/project -run TestAcc go test -count=1 -timeout=30m -v ./internal/resources/browserpool -run TestAcc go test -count=1 -timeout=30m -v ./internal/resources/extension -run TestAcc +go test -count=1 -timeout=30m -v ./internal/datasources/extension -run TestAcc go test -count=1 -timeout=30m -v ./internal/datasources/browserpool -run TestAcc ``` diff --git a/internal/acctest/extension.go b/internal/acctest/extension.go new file mode 100644 index 0000000..766095a --- /dev/null +++ b/internal/acctest/extension.go @@ -0,0 +1,51 @@ +package acctest + +import ( + "archive/zip" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" +) + +// ExtensionArchive writes a minimal Chrome extension ZIP and returns its path and SHA-256 checksum. +func ExtensionArchive(t testing.TB, marker string) (string, string) { + t.Helper() + + path := filepath.Join(t.TempDir(), "extension.zip") + file, err := os.Create(path) + if err != nil { + t.Fatalf("create extension archive: %v", err) + } + defer file.Close() + + writer := zip.NewWriter(file) + writeExtensionArchiveFile(t, writer, "manifest.json", `{"manifest_version":3,"name":"Kernel Terraform acceptance","version":"1.0.0"}`) + writeExtensionArchiveFile(t, writer, "marker.txt", marker) + if err := writer.Close(); err != nil { + t.Fatalf("close extension ZIP: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("close extension archive: %v", err) + } + + contents, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read extension archive: %v", err) + } + checksum := sha256.Sum256(contents) + return path, hex.EncodeToString(checksum[:]) +} + +func writeExtensionArchiveFile(t testing.TB, writer *zip.Writer, name, contents string) { + t.Helper() + + entry, err := writer.Create(name) + if err != nil { + t.Fatalf("create %s in extension ZIP: %v", name, err) + } + if _, err := entry.Write([]byte(contents)); err != nil { + t.Fatalf("write %s in extension ZIP: %v", name, err) + } +} diff --git a/internal/datasources/extension/datasource_acc_test.go b/internal/datasources/extension/datasource_acc_test.go new file mode 100644 index 0000000..0f8901a --- /dev/null +++ b/internal/datasources/extension/datasource_acc_test.go @@ -0,0 +1,109 @@ +package extension_test + +import ( + "context" + "fmt" + "os" + "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" + "github.com/kernel/terraform-provider-kernel/internal/projectscope" +) + +const extensionDataSourceFixtureName = "kernel_extension.data_source_test" + +func TestAccExtensionDataSourceByIDAndName(t *testing.T) { + name := acctest.UniqueName(t, "extension-data") + sourcePath, _ := acctest.ExtensionArchive(t, "data-source") + projectID := os.Getenv(acctest.EnvProjectID) + config := testAccExtensionDataSourceConfig(name, sourcePath, projectID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + if projectID == "" { + t.Fatalf("%s must be set for the extension data source acceptance test", acctest.EnvProjectID) + } + }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories(), + CheckDestroy: testAccCheckExtensionDataSourceFixtureDestroyed(), + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + testAccCaptureExtensionDataSourceFixture(t), + resource.TestCheckResourceAttrPair("data.kernel_extension.by_id", "id", extensionDataSourceFixtureName, "id"), + resource.TestCheckResourceAttrPair("data.kernel_extension.by_id", "name", extensionDataSourceFixtureName, "name"), + resource.TestCheckResourceAttrPair("data.kernel_extension.by_name", "id", extensionDataSourceFixtureName, "id"), + resource.TestCheckResourceAttrPair("data.kernel_extension.by_name", "name", extensionDataSourceFixtureName, "name"), + resource.TestCheckResourceAttr("data.kernel_extension.by_id", "project_id", projectID), + resource.TestCheckNoResourceAttr("data.kernel_extension.by_name", "project_id"), + resource.TestCheckResourceAttrSet("data.kernel_extension.by_id", "created_at"), + resource.TestCheckResourceAttrSet("data.kernel_extension.by_id", "size_bytes"), + resource.TestCheckNoResourceAttr("data.kernel_extension.by_id", "last_used_at"), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} + +func testAccExtensionDataSourceConfig(name, sourcePath, projectID string) string { + return acctest.ProviderConfig() + fmt.Sprintf(` +resource "kernel_extension" "data_source_test" { + name = %[1]q + source_path = %[2]q + source_sha256 = filesha256(%[2]q) +} + +data "kernel_extension" "by_id" { + id = kernel_extension.data_source_test.id + project_id = %[3]q +} + +data "kernel_extension" "by_name" { + name = kernel_extension.data_source_test.name +} +`, name, sourcePath, projectID) +} + +func testAccCaptureExtensionDataSourceFixture(t *testing.T) resource.TestCheckFunc { + t.Helper() + + return func(state *terraform.State) error { + resourceState, ok := state.RootModule().Resources[extensionDataSourceFixtureName] + if !ok || resourceState.Primary == nil || resourceState.Primary.ID == "" { + return fmt.Errorf("missing ID for %s", extensionDataSourceFixtureName) + } + acctest.CleanupExtension(t, resourceState.Primary.Attributes["project_id"], resourceState.Primary.ID) + return nil + } +} + +func testAccCheckExtensionDataSourceFixtureDestroyed() resource.TestCheckFunc { + return func(state *terraform.State) error { + resourceState, ok := state.RootModule().Resources[extensionDataSourceFixtureName] + if !ok || resourceState.Primary == nil || resourceState.Primary.ID == "" { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + projectID := resourceState.Primary.Attributes["project_id"] + _, err := acctest.ClientFromEnv().GetExtension(ctx, projectID, resourceState.Primary.ID) + if projectscope.IsNotFound(err) { + return nil + } + if err != nil { + return fmt.Errorf("read Kernel extension %s after destroy: %w", resourceState.Primary.ID, err) + } + return fmt.Errorf("Kernel extension %s still exists after destroy", resourceState.Primary.ID) + } +} diff --git a/internal/resources/extension/resource_acc_test.go b/internal/resources/extension/resource_acc_test.go index 9ccbfa0..0957d70 100644 --- a/internal/resources/extension/resource_acc_test.go +++ b/internal/resources/extension/resource_acc_test.go @@ -1,13 +1,9 @@ package extension_test import ( - "archive/zip" "context" - "crypto/sha256" - "encoding/hex" "fmt" "os" - "path/filepath" "testing" "time" @@ -20,8 +16,8 @@ const extensionResourceName = "kernel_extension.test" func TestAccExtensionLifecycle(t *testing.T) { name := acctest.UniqueName(t, "extension") - firstPath, firstChecksum := testAccWriteExtensionArchive(t, "first") - secondPath, secondChecksum := testAccWriteExtensionArchive(t, "second") + firstPath, firstChecksum := acctest.ExtensionArchive(t, "first") + secondPath, secondChecksum := acctest.ExtensionArchive(t, "second") firstConfig := testAccExtensionConfig(name, firstPath) secondConfig := testAccExtensionConfig(name, secondPath) var firstID, secondID string @@ -88,46 +84,6 @@ resource "kernel_extension" "test" { `, name, sourcePath, sourcePath) } -func testAccWriteExtensionArchive(t *testing.T, marker string) (string, string) { - t.Helper() - - path := filepath.Join(t.TempDir(), "extension.zip") - file, err := os.Create(path) - if err != nil { - t.Fatalf("create extension archive: %v", err) - } - defer file.Close() - - writer := zip.NewWriter(file) - testAccWriteZipFile(t, writer, "manifest.json", `{"manifest_version":3,"name":"Kernel Terraform acceptance","version":"1.0.0"}`) - testAccWriteZipFile(t, writer, "marker.txt", marker) - if err := writer.Close(); err != nil { - t.Fatalf("close extension ZIP: %v", err) - } - if err := file.Close(); err != nil { - t.Fatalf("close extension archive: %v", err) - } - - contents, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read extension archive: %v", err) - } - checksum := sha256.Sum256(contents) - return path, hex.EncodeToString(checksum[:]) -} - -func testAccWriteZipFile(t *testing.T, writer *zip.Writer, name, contents string) { - t.Helper() - - entry, err := writer.Create(name) - if err != nil { - t.Fatalf("create %s in extension ZIP: %v", name, err) - } - if _, err := entry.Write([]byte(contents)); err != nil { - t.Fatalf("write %s in extension ZIP: %v", name, err) - } -} - func testAccCaptureExtensionID(t *testing.T, resourceName string, extensionID *string) resource.TestCheckFunc { t.Helper() From 86b58f3fa810868d852c824a84b17287e9d0cc3e Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:41:21 -0400 Subject: [PATCH 68/68] Test project-qualified extension import --- docs/acceptance.md | 2 +- .../resources/extension/resource_acc_test.go | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/docs/acceptance.md b/docs/acceptance.md index 0585208..7a07756 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -40,7 +40,7 @@ operation. Secrets must remain GitHub Actions secrets and must not be printed. | --- | --- | --- | --- | | `kernel_project` resource | Test present | Create, rename, no-drift plan, import, delete, and 404 verification. | None. | | `kernel_browser_pool` resource | Test present | Create, durable update with stable ID, no-drift plan, bare and project-qualified import paths, explicit project scope, non-force delete, and 404 verification. | Keep leased-browser conflict behavior in unit tests; acceptance must not create runtime leases. | -| `kernel_extension` resource | Test present | Upload, checksum state, no-drift plan, metadata-only import, content replacement with new ID, old-ID disappearance, delete, and 404 verification. | Add project-qualified import to live coverage before the v1 tag. | +| `kernel_extension` resource | Test present | Upload, checksum state, no-drift plan, bare and project-qualified metadata-only import paths, content replacement with new ID, old-ID disappearance, delete, and 404 verification. | None. | | `kernel_browser_pool` data source | Test present | A uniquely created pool is read by canonical ID and byte-exact name, including normalized durable configuration, a no-drift plan, and post-destroy coded `not_found` verification. | None. | | `kernel_project` data source | Test present | A uniquely created project is read by canonical ID and exact name; the provider default resolves the configured project; metadata, no-drift planning, and post-destroy coded `not_found` are verified. | None. | | `kernel_extension` data source | Test present | A uniquely uploaded extension is read by canonical ID and exact name through explicit and provider-default project scope; metadata, no-drift planning, and post-destroy coded `not_found` are verified. | None. | diff --git a/internal/resources/extension/resource_acc_test.go b/internal/resources/extension/resource_acc_test.go index 0957d70..7db4ecb 100644 --- a/internal/resources/extension/resource_acc_test.go +++ b/internal/resources/extension/resource_acc_test.go @@ -74,6 +74,54 @@ func TestAccExtensionLifecycle(t *testing.T) { }) } +func TestAccExtensionProjectScopedImport(t *testing.T) { + projectID := os.Getenv(acctest.EnvAltProjectID) + if projectID == "" { + projectID = os.Getenv(acctest.EnvProjectID) + } + if projectID == "" { + t.Skipf("%s or %s must be set for the project-scoped extension test", acctest.EnvAltProjectID, acctest.EnvProjectID) + } + + name := acctest.UniqueName(t, "extension-scoped") + sourcePath, checksum := acctest.ExtensionArchive(t, "project-scoped") + config := testAccExtensionProjectConfig(name, sourcePath, projectID) + var extensionID string + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories(), + CheckDestroy: testAccCheckExtensionDestroyed(), + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + testAccCaptureExtensionID(t, extensionResourceName, &extensionID), + resource.TestCheckResourceAttr(extensionResourceName, "project_id", projectID), + resource.TestCheckResourceAttr(extensionResourceName, "source_sha256", checksum), + ), + }, + { + ResourceName: extensionResourceName, + ImportState: true, + ImportStateVerify: true, + ImportStatePersist: true, + ImportStateIdFunc: func(state *terraform.State) (string, error) { + id, stateProjectID, err := extensionStateValues(state, extensionResourceName) + if err != nil { + return "", err + } + return stateProjectID + "/" + id, nil + }, + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} + func testAccExtensionConfig(name, sourcePath string) string { return acctest.ProviderConfig() + fmt.Sprintf(` resource "kernel_extension" "test" { @@ -84,6 +132,17 @@ resource "kernel_extension" "test" { `, name, sourcePath, sourcePath) } +func testAccExtensionProjectConfig(name, sourcePath, projectID string) string { + return acctest.ProviderConfig() + fmt.Sprintf(` +resource "kernel_extension" "test" { + name = %[1]q + source_path = %[2]q + source_sha256 = filesha256(%[2]q) + project_id = %[3]q +} +`, name, sourcePath, projectID) +} + func testAccCaptureExtensionID(t *testing.T, resourceName string, extensionID *string) resource.TestCheckFunc { t.Helper()