From 88438e4ec8a9cfc5ea4b53a79ade51fa0413f7b4 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 04:50:11 -0400 Subject: [PATCH] Test the extension lifecycle against Kernel Add an opt-in Terraform acceptance test that uploads a valid extension archive, verifies stable plans and import, replaces content by checksum, confirms the old object is deleted, and checks final cleanup. Extend the manual acceptance matrix and release guidance so the durable extension resource has an explicit real-API release gate. --- .github/workflows/acceptance.yml | 5 +- README.md | 14 +- docs/release.md | 10 +- internal/acctest/acctest.go | 42 ++++ internal/acctest/acctest_test.go | 112 +++++++++ .../resources/extension/resource_acc_test.go | 219 ++++++++++++++++++ 6 files changed, 393 insertions(+), 9 deletions(-) create mode 100644 internal/resources/extension/resource_acc_test.go diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 41ba489..8cf7b0b 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -24,6 +24,9 @@ jobs: - name: Project package: ./internal/resources/project project_id_required: false + - name: Extension + package: ./internal/resources/extension + project_id_required: true # Keep the job timeout above the go test timeout so Go can report the test # timeout before the runner stops the job. Either hard timeout can bypass # t.Cleanup, so release operators still inspect for leaked test resources. @@ -44,7 +47,7 @@ jobs: env: KERNEL_PROJECT_ID: ${{ secrets.KERNEL_PROJECT_ID }} run: | - : "${KERNEL_PROJECT_ID:?Set the KERNEL_PROJECT_ID repository secret before running browser-pool acceptance tests.}" + : "${KERNEL_PROJECT_ID:?Set the KERNEL_PROJECT_ID repository secret before running project-scoped acceptance tests.}" - name: Checkout uses: actions/checkout@v6 diff --git a/README.md b/README.md index 39aecf4..0445ea6 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Provider configuration: Resources: - `kernel_browser_pool` +- `kernel_extension` - `kernel_project` Data sources: @@ -27,6 +28,7 @@ Data sources: Import: - `kernel_browser_pool` by canonical browser pool ID +- `kernel_extension` by canonical extension ID, optionally qualified with its project ID - `kernel_project` by canonical project ID ## Not Supported @@ -39,8 +41,8 @@ The provider intentionally does not manage: - logs, screenshots, or live view - runtime status or standby state - force-release or recovery operations -- API key, profile, proxy, or extension resources -- extension upload, download, delete, or Chrome Store operations +- API key, profile, proxy, or deployment resources +- extension download or Chrome Web Store download operations ## Quickstart @@ -83,6 +85,8 @@ use the project-qualified form to import from a different project: ```sh terraform import kernel_browser_pool.example terraform import kernel_browser_pool.example / +terraform import kernel_extension.example +terraform import kernel_extension.example / terraform import kernel_project.example ``` @@ -151,12 +155,14 @@ Run the durable resource acceptance tests: ```sh go test -count=1 -timeout=30m -v ./internal/resources/browserpool -run TestAcc +go test -count=1 -timeout=30m -v ./internal/resources/extension -run TestAcc go test -count=1 -timeout=30m -v ./internal/resources/project -run TestAcc ``` The tests create uniquely named durable resources and register independent -cleanup. Browser-pool deletion remains `force=false`. The tests do not acquire -browsers or perform runtime recovery. +cleanup. Extension acceptance creates a small temporary Manifest V3 archive and +tests checksum-driven replacement. Browser-pool deletion remains `force=false`. +The tests do not acquire browsers or perform runtime recovery. ## Architecture diff --git a/docs/release.md b/docs/release.md index 7a4508d..276aff3 100644 --- a/docs/release.md +++ b/docs/release.md @@ -14,13 +14,15 @@ Use this checklist before publishing a Kernel Terraform provider version. - Run `go vet ./...`. - Run the complete opt-in acceptance matrix for every v1 resource and data source with real credentials before the first public release. - Browser pools: `TF_ACC=1 KERNEL_ACC=1 KERNEL_API_KEY=... KERNEL_PROJECT_ID=... go test -count=1 -timeout=30m -v ./internal/resources/browserpool -run TestAcc`. + - Extensions: `TF_ACC=1 KERNEL_ACC=1 KERNEL_API_KEY=... KERNEL_PROJECT_ID=... go test -count=1 -timeout=30m -v ./internal/resources/extension -run TestAcc`. - Projects: `TF_ACC=1 KERNEL_ACC=1 KERNEL_API_KEY=... go test -count=1 -timeout=30m -v ./internal/resources/project -run TestAcc`. - - The manual `Acceptance` workflow runs both packages in parallel; expand its matrix as v1 resources land and keep live tests out of normal PR CI. + - The manual `Acceptance` workflow runs all three packages in parallel; expand its matrix as v1 resources land and keep live tests out of normal PR CI. - Process-level timeouts can bypass Go test cleanup. After an interrupted or hard-timeout run: - 1. In the Kernel dashboard or durable API, find projects and browser pools named `kernel-tf-*` that were created during the failed workflow run. + 1. In the Kernel dashboard or durable API, find projects, browser pools, and extensions named `kernel-tf-*` that were created during the failed workflow run. 2. Delete leaked browser pools first with `force=false`. If deletion conflicts with a lease, wait for the lease to end; do not force-release or recover the browser from Terraform cleanup. - 3. Delete a leaked project only after its child resources are gone and the organization still has another active project. - 4. Read each canonical resource ID and require a 404 before considering cleanup complete. + 3. Delete leaked extensions after removing any durable browser-pool references to them. Do not mutate pools or running browsers implicitly. + 4. Delete a leaked project only after its child resources are gone and the organization still has another active project. + 5. Read each canonical resource ID and require a 404 before considering cleanup complete. - Verify unscoped API calls send no `X-Kernel-Project-Id` header; it is sent only when a resource-level `project_id` or the provider default resolves a project. - Confirm `terraform-registry-manifest.json` contains protocol `["6.0"]` for Terraform Plugin Framework. - Confirm the repository license before the first public release. Do not publish a public tag until `LICENSE` exists or the release owner has explicitly documented the licensing decision. diff --git a/internal/acctest/acctest.go b/internal/acctest/acctest.go index 03b92c2..1571511 100644 --- a/internal/acctest/acctest.go +++ b/internal/acctest/acctest.go @@ -159,6 +159,48 @@ func cleanupProject(t testing.TB, client projectCleaner, id string) { }) } +// CleanupExtension registers a cleanup that deletes the extension from +// projectID; empty means the env-configured default project. +func CleanupExtension(t testing.TB, projectID, id string) { + t.Helper() + + cleanupExtension(t, ClientFromEnv(), projectID, id) +} + +type extensionCleaner interface { + DefaultProjectID() string + DeleteExtension(context.Context, string, string) error +} + +func cleanupExtension(t testing.TB, client extensionCleaner, projectID, id string) { + t.Helper() + + if id == "" { + return + } + if !AcceptanceEnabled() { + t.Fatalf("%s must be set to clean up Kernel acceptance test resources", EnvAcceptance) + return + } + if os.Getenv(EnvAPIKey) == "" { + t.Fatalf("%s must be set to clean up Kernel acceptance test resources", EnvAPIKey) + return + } + + if projectID == "" { + projectID = client.DefaultProjectID() + } + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) + defer cancel() + + if err := client.DeleteExtension(ctx, projectID, id); err != nil && !IsNotFound(err) { + t.Errorf("cleanup Kernel extension %s: %v", id, err) + } + }) +} + func ClientFromEnv() kernelclient.Clients { return kernelclient.New(kernelclient.Config{ APIKey: os.Getenv(EnvAPIKey), diff --git a/internal/acctest/acctest_test.go b/internal/acctest/acctest_test.go index 2b246ae..226b0c3 100644 --- a/internal/acctest/acctest_test.go +++ b/internal/acctest/acctest_test.go @@ -21,6 +21,19 @@ type fakeProjectCleaner struct { delete func(context.Context, string) error } +type fakeExtensionCleaner struct { + defaultProjectID string + delete func(context.Context, string, string) error +} + +func (f fakeExtensionCleaner) DefaultProjectID() string { + return f.defaultProjectID +} + +func (f fakeExtensionCleaner) DeleteExtension(ctx context.Context, projectID, id string) error { + return f.delete(ctx, projectID, id) +} + func (f fakeProjectCleaner) DeleteProject(ctx context.Context, id string) error { return f.delete(ctx, id) } @@ -343,6 +356,105 @@ func TestCleanupProject(t *testing.T) { } } +func TestCleanupExtension(t *testing.T) { + tests := map[string]struct { + acceptance string + apiKey string + projectID string + defaultProject string + id string + deleteErr error + wantProjectID string + wantCleanups int + wantDelete bool + wantFailure bool + }{ + "empty ID is ignored": {}, + "acceptance disabled": { + apiKey: "test-key", + id: "extension_123", + wantFailure: true, + }, + "API key missing": { + acceptance: "1", + id: "extension_123", + wantFailure: true, + }, + "default project is resolved": { + acceptance: "1", + apiKey: "test-key", + defaultProject: "project_default", + id: "extension_123", + wantProjectID: "project_default", + wantCleanups: 1, + wantDelete: true, + }, + "explicit project wins": { + acceptance: "1", + apiKey: "test-key", + projectID: "project_explicit", + defaultProject: "project_default", + id: "extension_123", + deleteErr: notFoundAPIError(), + wantProjectID: "project_explicit", + wantCleanups: 1, + wantDelete: true, + }, + "delete error is reported": { + acceptance: "1", + apiKey: "test-key", + projectID: "project_explicit", + id: "extension_123", + deleteErr: errors.New("connection reset"), + wantProjectID: "project_explicit", + wantCleanups: 1, + wantDelete: true, + wantFailure: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Setenv(EnvAcceptance, test.acceptance) + t.Setenv(EnvAPIKey, test.apiKey) + + var gotID, gotProjectID string + deleteCalled := false + deadlineSet := false + recorder := &testRecorder{TB: t} + cleanupExtension(recorder, fakeExtensionCleaner{ + defaultProjectID: test.defaultProject, + delete: func(ctx context.Context, projectID, id string) error { + deleteCalled = true + _, deadlineSet = ctx.Deadline() + gotProjectID = projectID + gotID = id + return test.deleteErr + }, + }, test.projectID, test.id) + + if got, want := len(recorder.cleanups), test.wantCleanups; got != want { + t.Fatalf("cleanupExtension registered %d cleanups, want %d", got, want) + } + if test.wantCleanups == 1 { + recorder.cleanups[0]() + } + if recorder.failed != test.wantFailure { + t.Fatalf("cleanupExtension failure = %t, want %t", recorder.failed, test.wantFailure) + } + if deleteCalled != test.wantDelete { + t.Fatalf("cleanupExtension called delete = %t, want %t", deleteCalled, test.wantDelete) + } + if deleteCalled && !deadlineSet { + t.Fatal("cleanupExtension called delete without a context deadline") + } + if test.wantDelete && (gotID != test.id || gotProjectID != test.wantProjectID) { + t.Fatalf("cleanup extension scope/id = %q/%q, want %q/%q", gotProjectID, gotID, test.wantProjectID, test.id) + } + }) + } +} + func TestUniqueNameIsKernelScopedAndSafe(t *testing.T) { t.Parallel() diff --git a/internal/resources/extension/resource_acc_test.go b/internal/resources/extension/resource_acc_test.go new file mode 100644 index 0000000..9ccbfa0 --- /dev/null +++ b/internal/resources/extension/resource_acc_test.go @@ -0,0 +1,219 @@ +package extension_test + +import ( + "archive/zip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/kernel/terraform-provider-kernel/internal/acctest" +) + +const extensionResourceName = "kernel_extension.test" + +func TestAccExtensionLifecycle(t *testing.T) { + name := acctest.UniqueName(t, "extension") + firstPath, firstChecksum := testAccWriteExtensionArchive(t, "first") + secondPath, secondChecksum := testAccWriteExtensionArchive(t, "second") + firstConfig := testAccExtensionConfig(name, firstPath) + secondConfig := testAccExtensionConfig(name, secondPath) + var firstID, secondID string + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + if os.Getenv(acctest.EnvProjectID) == "" { + t.Fatalf("%s must be set for extension acceptance tests", acctest.EnvProjectID) + } + }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories(), + CheckDestroy: testAccCheckExtensionDestroyed(), + Steps: []resource.TestStep{ + { + Config: firstConfig, + Check: resource.ComposeAggregateTestCheckFunc( + testAccCaptureExtensionID(t, extensionResourceName, &firstID), + resource.TestCheckResourceAttrSet(extensionResourceName, "id"), + resource.TestCheckResourceAttr(extensionResourceName, "name", name), + resource.TestCheckResourceAttr(extensionResourceName, "project_id", os.Getenv(acctest.EnvProjectID)), + resource.TestCheckResourceAttr(extensionResourceName, "source_sha256", firstChecksum), + ), + }, + { + Config: firstConfig, + PlanOnly: true, + }, + { + ResourceName: extensionResourceName, + ImportState: true, + ImportStateVerify: true, + ImportStatePersist: true, + }, + { + Config: firstConfig, + PlanOnly: true, + }, + { + Config: secondConfig, + Check: resource.ComposeAggregateTestCheckFunc( + testAccCaptureExtensionID(t, extensionResourceName, &secondID), + testAccCheckExtensionReplaced(extensionResourceName, &firstID), + testAccCheckExtensionGone(&firstID), + resource.TestCheckResourceAttr(extensionResourceName, "name", name), + resource.TestCheckResourceAttr(extensionResourceName, "source_sha256", secondChecksum), + ), + }, + { + Config: secondConfig, + PlanOnly: true, + }, + }, + }) +} + +func testAccExtensionConfig(name, sourcePath string) string { + return acctest.ProviderConfig() + fmt.Sprintf(` +resource "kernel_extension" "test" { + name = %q + source_path = %q + source_sha256 = filesha256(%q) +} +`, name, sourcePath, sourcePath) +} + +func testAccWriteExtensionArchive(t *testing.T, marker string) (string, string) { + t.Helper() + + path := filepath.Join(t.TempDir(), "extension.zip") + file, err := os.Create(path) + if err != nil { + t.Fatalf("create extension archive: %v", err) + } + defer file.Close() + + writer := zip.NewWriter(file) + testAccWriteZipFile(t, writer, "manifest.json", `{"manifest_version":3,"name":"Kernel Terraform acceptance","version":"1.0.0"}`) + testAccWriteZipFile(t, writer, "marker.txt", marker) + if err := writer.Close(); err != nil { + t.Fatalf("close extension ZIP: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("close extension archive: %v", err) + } + + contents, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read extension archive: %v", err) + } + checksum := sha256.Sum256(contents) + return path, hex.EncodeToString(checksum[:]) +} + +func testAccWriteZipFile(t *testing.T, writer *zip.Writer, name, contents string) { + t.Helper() + + entry, err := writer.Create(name) + if err != nil { + t.Fatalf("create %s in extension ZIP: %v", name, err) + } + if _, err := entry.Write([]byte(contents)); err != nil { + t.Fatalf("write %s in extension ZIP: %v", name, err) + } +} + +func testAccCaptureExtensionID(t *testing.T, resourceName string, extensionID *string) resource.TestCheckFunc { + t.Helper() + + return func(state *terraform.State) error { + id, projectID, err := extensionStateValues(state, resourceName) + if err != nil { + return err + } + *extensionID = id + acctest.CleanupExtension(t, projectID, id) + return nil + } +} + +func testAccCheckExtensionReplaced(resourceName string, previousID *string) resource.TestCheckFunc { + return func(state *terraform.State) error { + id, _, err := extensionStateValues(state, resourceName) + if err != nil { + return err + } + if id == *previousID { + return fmt.Errorf("Kernel extension ID remained %s after content replacement", id) + } + return nil + } +} + +func testAccCheckExtensionGone(extensionID *string) resource.TestCheckFunc { + return func(*terraform.State) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + client := acctest.ClientFromEnv() + projectID := os.Getenv(acctest.EnvProjectID) + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + _, err := client.GetExtension(ctx, projectID, *extensionID) + if acctest.IsNotFound(err) { + return nil + } + if err != nil { + return fmt.Errorf("read replaced Kernel extension %s: %w", *extensionID, err) + } + + select { + case <-ctx.Done(): + return fmt.Errorf("replaced Kernel extension %s still exists after 30 seconds", *extensionID) + case <-ticker.C: + } + } + } +} + +func testAccCheckExtensionDestroyed() resource.TestCheckFunc { + return func(state *terraform.State) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + client := acctest.ClientFromEnv() + for _, resourceState := range state.RootModule().Resources { + if resourceState.Type != "kernel_extension" || resourceState.Primary == nil || resourceState.Primary.ID == "" { + continue + } + + projectID := resourceState.Primary.Attributes["project_id"] + _, err := client.GetExtension(ctx, projectID, resourceState.Primary.ID) + if acctest.IsNotFound(err) { + continue + } + if err != nil { + return fmt.Errorf("read Kernel extension %s after destroy: %w", resourceState.Primary.ID, err) + } + return fmt.Errorf("Kernel extension %s still exists after destroy", resourceState.Primary.ID) + } + return nil + } +} + +func extensionStateValues(state *terraform.State, resourceName string) (id, projectID string, err error) { + resourceState, ok := state.RootModule().Resources[resourceName] + if !ok { + return "", "", fmt.Errorf("missing resource %s in Terraform state", resourceName) + } + if resourceState.Primary == nil || resourceState.Primary.ID == "" { + return "", "", fmt.Errorf("missing ID for %s in Terraform state", resourceName) + } + return resourceState.Primary.ID, resourceState.Primary.Attributes["project_id"], nil +}