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 +}