diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 30cbf15..794bae1 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -11,22 +11,53 @@ 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: Browser pool data source + package: ./internal/datasources/browserpool + project_id_required: true + - 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 + - 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. 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 project-scoped acceptance tests.}" + - name: Checkout uses: actions/checkout@v6 @@ -42,10 +73,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 8ee54e0..7fe3dc9 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,12 +12,15 @@ Provider configuration: - `base_url` - `project_id` -Resource: +Resources: - `kernel_browser_pool` +- `kernel_extension` +- `kernel_project` Data sources: +- `kernel_app` - `kernel_project` - `kernel_profile` - `kernel_proxy` @@ -26,8 +29,10 @@ 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 In v0 +## Not Supported The provider intentionally does not manage: @@ -37,8 +42,8 @@ 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 -- extension upload, download, delete, or Chrome Store operations +- API key, profile, proxy, or deployment resources +- extension download or Chrome Web Store download operations ## Quickstart @@ -74,11 +79,16 @@ 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_extension.example +terraform import kernel_extension.example / +terraform import kernel_project.example ``` ## Local Development @@ -128,22 +138,27 @@ 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="..." -export KERNEL_PROJECT_ID="..." ``` -Run the browser-pool acceptance test: +Browser-pool acceptance tests additionally require: ```sh -go test -v ./internal/resources/browserpool -run TestAccBrowserPoolLifecycle +export KERNEL_PROJECT_ID="..." ``` -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. 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 diff --git a/docs/acceptance.md b/docs/acceptance.md new file mode 100644 index 0000000..7a07756 --- /dev/null +++ b/docs/acceptance.md @@ -0,0 +1,92 @@ +# 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, 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. | +| `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 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 +``` + +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/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 71e4b5f..0fe58c1 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,80 @@ 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/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 + +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 +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). ## 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 +119,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 +131,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 +141,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,12 +182,98 @@ 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. `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. 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 +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. @@ -138,17 +281,34 @@ 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 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 @@ -162,12 +322,21 @@ 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 - create uniquely named resources - clean up after themselves - 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 @@ -177,26 +346,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 +381,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/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. 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/docs/data-sources/browser_pool.md b/docs/data-sources/browser_pool.md new file mode 100644 index 0000000..7b803bf --- /dev/null +++ b/docs/data-sources/browser_pool.md @@ -0,0 +1,46 @@ +--- +# 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 + +- `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. +- `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. +- `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/docs/release.md b/docs/release.md index ac42051..73cbd7b 100644 --- a/docs/release.md +++ b/docs/release.md @@ -12,18 +12,23 @@ 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 [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. + 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. - 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 +60,19 @@ 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 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 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. -- 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. diff --git a/docs/resources/extension.md b/docs/resources/extension.md new file mode 100644 index 0000000..0efe977 --- /dev/null +++ b/docs/resources/extension.md @@ -0,0 +1,61 @@ +--- +# 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. + +## 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 + +### 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. + +## 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/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/examples/README.md b/examples/README.md index 08085fd..73e0f55 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,12 +2,17 @@ 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 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="..." ``` @@ -17,5 +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. -- [lookups](lookups) shows read-only project, profile, proxy, and extension data sources. +- [extension](extension) uploads an immutable extension archive and tracks exact content changes with `filesha256`. +- [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/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/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/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 +} 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) +} 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= diff --git a/internal/acctest/acctest.go b/internal/acctest/acctest.go index 6994c3e..1571511 100644 --- a/internal/acctest/acctest.go +++ b/internal/acctest/acctest.go @@ -124,6 +124,83 @@ 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) + } + }) +} + +// 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 3ff0944..226b0c3 100644 --- a/internal/acctest/acctest_test.go +++ b/internal/acctest/acctest_test.go @@ -17,6 +17,27 @@ type fakeBrowserPoolCleaner struct { delete func(context.Context, string, string) error } +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) +} + func (f fakeBrowserPoolCleaner) DefaultProjectID() string { return f.defaultProjectID } @@ -248,6 +269,192 @@ 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 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/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/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/datasources/browserpool/datasource.go b/internal/datasources/browserpool/datasource.go new file mode 100644 index 0000000..e41ccc6 --- /dev/null +++ b/internal/datasources/browserpool/datasource.go @@ -0,0 +1,507 @@ +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" + "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" +) + +var ( + _ datasource.DataSource = (*browserPoolDataSource)(nil) + _ datasource.DataSourceWithConfigure = (*browserPoolDataSource)(nil) +) + +const ( + minBrowserPoolTimeoutSeconds = 10 + maxBrowserPoolTimeoutSeconds = 259200 + minBrowserPoolFillRate = 0 + minBrowserPoolViewportValue = 1 +) + +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"` + 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"` + Viewport types.Object `tfsdk:"viewport"` + ChromePolicy types.String `tfsdk:"chrome_policy"` +} + +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.", + }, + "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.", + }, + "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.", + }, + "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.", + }, + "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."}, + }, + }, + "chrome_policy": dschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Normalized JSON object of Chrome enterprise policy overrides, if configured.", + }, + }, + } +} + +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 + } + + 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), + 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), + ChromePolicy: flattenChromePolicy(config.JSON.ChromePolicy.Raw(), config.JSON.ChromePolicy.Valid(), &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 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 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 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 != "" { + 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 + } + 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_acc_test.go b/internal/datasources/browserpool/datasource_acc_test.go new file mode 100644 index 0000000..a13eab4 --- /dev/null +++ b/internal/datasources/browserpool/datasource_acc_test.go @@ -0,0 +1,148 @@ +package browserpool_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 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(), + CheckDestroy: testAccCheckBrowserPoolDataSourceDestroyed(), + 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 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 projectscope.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" { + 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"), + ) +} diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go new file mode 100644 index 0000000..985c7e3 --- /dev/null +++ b/internal/datasources/browserpool/datasource_test.go @@ -0,0 +1,637 @@ +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", "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) + } + } + 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 browserPoolFromJSON(`{ + "id":"pool-1", + "name":"Pool", + "extension_ids":[], + "browser_pool_config":{ + "size":2, + "proxy_id":"proxy-1", + "headless":true, + "kiosk_mode":false, + "stealth":true, + "start_url":"chrome://newtab", + "timeout_seconds":10, + "fill_rate_per_minute":0, + "viewport":{"width":1280,"height":800,"refresh_rate":60}, + "chrome_policy":{"RestoreOnStartup":4,"HomepageLocation":"https://example.com?x=1&y=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) + } + if !state.ProfileID.IsNull() { + 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) + } + 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)) + 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) { + 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) { + 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) { + 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) { + 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) { + 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 + `,"extension_ids":[],"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 { + 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, + "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, + "viewport": viewportType, + "chrome_policy": tftypes.String, + }}, + 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), + "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), + "chrome_policy": tftypes.NewValue(tftypes.String, 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() { + 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]) + } + } +} 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/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) + } +} diff --git a/internal/kernelclient/client.go b/internal/kernelclient/client.go index 93adab6..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...), } @@ -78,12 +81,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. @@ -118,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)...) } @@ -157,6 +203,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 1050ab6..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() @@ -297,25 +351,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,13 +407,34 @@ 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") }, }, + "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 { @@ -338,8 +443,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 +466,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) } }) @@ -411,6 +517,21 @@ 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) + } + } +} + +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 65492fd..cfc1ec8 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -7,12 +7,16 @@ 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" + 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" - "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" + extensionresource "github.com/kernel/terraform-provider-kernel/internal/resources/extension" + projectresource "github.com/kernel/terraform-provider-kernel/internal/resources/project" ) var _ provider.Provider = (*kernelProvider)(nil) @@ -81,14 +85,18 @@ 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, } } func (p *kernelProvider) DataSources(ctx context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{ - project.NewDataSource, + appdatasource.NewDataSource, + browserpooldatasource.NewDataSource, + 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 cc0d388..b6f2294 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) != 3 { + t.Fatalf("Resources length = %d, want 3", 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_extension", "kernel_project"} { + if !got[want] { + t.Fatalf("missing resource %s; got %v", want, got) + } + } } func TestProviderRegistersDataSources(t *testing.T) { @@ -56,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) != 6 { + t.Fatalf("DataSources length = %d, want 6", len(dataSources)) } got := make(map[string]bool, len(dataSources)) @@ -71,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_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) } diff --git a/internal/resources/browserpool/flatten.go b/internal/resources/browserpool/flatten.go index 906253a..18934f4 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,36 @@ 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 != "" { + 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) + } + 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 +183,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..56cea22 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", @@ -123,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 } @@ -163,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 } @@ -305,6 +329,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": `{ 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) + } +} 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_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) + } + } + }) + } +} 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 +} 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") + } + }) + } +} diff --git a/internal/resources/extension/expand.go b/internal/resources/extension/expand.go new file mode 100644 index 0000000..2326b54 --- /dev/null +++ b/internal/resources/extension/expand.go @@ -0,0 +1,49 @@ +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.", + ) + } else 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, + ), + ) + } + if config.Name.IsUnknown() { + diags.AddAttributeError( + path.Root("name"), + "Invalid Extension Name", + "name must be known before uploading a Kernel extension.", + ) + } + if diags.HasError() { + 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..aa3af09 --- /dev/null +++ b/internal/resources/extension/expand_test.go @@ -0,0 +1,149 @@ +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" + kernel "github.com/kernel/kernel-go-sdk" +) + +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)} + params, diags := expandExtensionUpload(extensionModel{ + Name: types.StringUnknown(), + SourceSHA256: types.StringValue(snapshot.checksum), + }, snapshot) + assertEmptyExtensionUploadParams(t, params) + 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() + params, diags := expandExtensionUpload(extensionModel{ + Name: types.StringNull(), + SourceSHA256: value, + }, archiveSnapshot{}) + assertEmptyExtensionUploadParams(t, params) + 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) + 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() + + 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) + } +} + +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 +} 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 +} 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"), + } +} 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(), + } +} 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(), + } +} 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/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 +} 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/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/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 +} 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_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 +} 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) + } + }) + } +} 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) +} diff --git a/internal/resources/extension/resource_acc_test.go b/internal/resources/extension/resource_acc_test.go new file mode 100644 index 0000000..7db4ecb --- /dev/null +++ b/internal/resources/extension/resource_acc_test.go @@ -0,0 +1,234 @@ +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" +) + +const extensionResourceName = "kernel_extension.test" + +func TestAccExtensionLifecycle(t *testing.T) { + name := acctest.UniqueName(t, "extension") + firstPath, firstChecksum := acctest.ExtensionArchive(t, "first") + secondPath, secondChecksum := acctest.ExtensionArchive(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 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" { + name = %q + source_path = %q + source_sha256 = filesha256(%q) +} +`, 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() + + 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 +} diff --git a/internal/resources/extension/schema.go b/internal/resources/extension/schema.go new file mode 100644 index 0000000..074ebcf --- /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. 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{}, + }, + }, + "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. Adding or 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)`. 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"), + }, + }, + }, + } +} + +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..574147b --- /dev/null +++ b/internal/resources/extension/schema_test.go @@ -0,0 +1,206 @@ +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.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, + 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), + ) +} diff --git a/internal/resources/project/convert.go b/internal/resources/project/convert.go new file mode 100644 index 0000000..8ce7054 --- /dev/null +++ b/internal/resources/project/convert.go @@ -0,0 +1,86 @@ +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 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) { + 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..e4869c0 --- /dev/null +++ b/internal/resources/project/convert_test.go @@ -0,0 +1,179 @@ +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, "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.") + }) + } +} + +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, wantDetail string) { + 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 := 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")) { + 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/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/framework_create_test.go b/internal/resources/project/framework_create_test.go new file mode 100644 index 0000000..a4a0c9c --- /dev/null +++ b/internal/resources/project/framework_create_test.go @@ -0,0 +1,177 @@ +package project + +import ( + "context" + "errors" + "net/http" + "strings" + "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") + } + 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) + } + 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]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.", + }, + "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, 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 test.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 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) + } + }) + } +} + +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/framework_delete_test.go b/internal/resources/project/framework_delete_test.go new file mode 100644 index 0000000..67aed69 --- /dev/null +++ b/internal/resources/project/framework_delete_test.go @@ -0,0 +1,91 @@ +package project + +import ( + "context" + "errors" + "strings" + "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") + } + 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) { + 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/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/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/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/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/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/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/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 new file mode 100644 index 0000000..64ebcd7 --- /dev/null +++ b/internal/resources/project/resource.go @@ -0,0 +1,413 @@ +package project + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + + "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" +) + +var ( + _ tfresource.Resource = (*projectResource)(nil) + _ tfresource.ResourceWithConfigure = (*projectResource)(nil) + _ tfresource.ResourceWithImportState = (*projectResource)(nil) +) + +type projectCreateResult struct { + State projectModel + UncertainErr error +} + +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 { + client projectClient +} + +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 == "" { + 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, 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) + resp.Diagnostics.Append(createDiags...) + if resp.Diagnostics.HasError() { + return + } + + if result.UncertainErr == nil { + resp.Diagnostics.Append(resp.State.Set(ctx, result.State)...) + return + } + + 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) 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) 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) 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) 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 { + addMissingClientDiagnostic(&diags) + return projectCreateResult{}, diags + } + + params, expandDiags := expandProjectCreate(plan) + diags.Append(expandDiags...) + if diags.HasError() { + 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{}, diags + } + return projectCreateResult{UncertainErr: err}, diags + } + if created == nil { + return projectCreateResult{UncertainErr: errors.New("Kernel returned an empty project response.")}, diags + } + + state, flattenDiags := flattenProject(*created) + if flattenDiags.HasError() { + reasons := make([]string, 0, len(flattenDiags)) + for _, flattenDiag := range flattenDiags { + reasons = append(reasons, flattenDiag.Detail()) + } + return projectCreateResult{ + State: partialProjectState(*created, plan), + UncertainErr: errors.New(strings.Join(reasons, " ")), + }, diags + } + if !state.Name.Equal(plan.Name) { + return projectCreateResult{ + 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}, 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 (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 (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", + "The Kernel provider was not configured before using the project resource.", + ) +} + +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 +} + +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{} + } + return projectModel{ + ID: types.StringValue(project.ID), + Name: plan.Name, + } +} + +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. "+ + recovery+" "+ + "Reason: "+reason, + ) +} 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 +} diff --git a/internal/resources/project/resource_test.go b/internal/resources/project/resource_test.go new file mode 100644 index 0000000..56a83ba --- /dev/null +++ b/internal/resources/project/resource_test.go @@ -0,0 +1,234 @@ +package project + +import ( + "context" + "encoding/json" + "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) + 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) { + if f.create == nil { + return nil, errors.New("unexpected create") + } + 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 (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 (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() + + 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.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) + } + 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() + + 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 + wantSummary string + wantDetail string + wantUncertain bool + wantReason string + wantID string + wantName string + }{ + "missing client": { + resource: &projectResource{}, + 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, conflictError + }, + }), + 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") + }, + }), + 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{ + create: func(ctx context.Context, params kernel.ProjectNewParams) (*kernel.Project, error) { + return nil, nil + }, + }), + wantUncertain: true, + wantReason: "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) { + return &malformedSuccess, nil + }, + }), + wantUncertain: true, + wantReason: "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) { + return &mismatchedSuccess, nil + }, + }), + wantUncertain: true, + wantReason: `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 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 got := result.UncertainErr != nil; got != test.wantUncertain { + t.Fatalf("uncertain = %t, want %t (error: %v)", got, test.wantUncertain, result.UncertainErr) + } + 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) + } + if got := result.State.Name.ValueString(); got != test.wantName { + t.Fatalf("state name = %q, want %q", got, test.wantName) + } + }) + } +} + +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 diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + for _, reason := range []string{ + "missing or invalid field id", + "missing or invalid field name", + } { + if result.UncertainErr == nil || !strings.Contains(result.UncertainErr.Error(), reason) { + t.Fatalf("uncertain error = %v, want it to contain %q", result.UncertainErr, reason) + } + } +} + +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 +} 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 +} 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) + } +}