From eede1129b80b5211bb537c0d77f29ba1497f7910 Mon Sep 17 00:00:00 2001 From: Camila Macedo <7708031+camilamacedo86@users.noreply.github.com> Date: Sun, 4 Jan 2026 13:20:03 +0000 Subject: [PATCH] (chore) Add delete interface and implementation for all options Add delete functionality to remove APIs and webhooks from projects. Users can now clean up scaffolded resources. Generated-by: Cursor/Claude --- .gitignore | 2 +- AGENTS.md | 70 +- .../plugins/available/autoupdate-v1-alpha.md | 6 + .../available/deploy-image-plugin-v1-alpha.md | 20 +- .../src/plugins/available/go-v4-plugin.md | 2 + .../src/plugins/available/grafana-v1-alpha.md | 17 +- .../src/plugins/available/helm-v2-alpha.md | 13 + .../src/plugins/available/kustomize-v2.md | 14 +- .../extending_cli_features_and_plugins.md | 4 +- .../src/plugins/extending/external-plugins.md | 2 + docs/book/src/plugins/plugins.md | 4 + pkg/cli/cli.go | 38 +- pkg/cli/delete.go | 93 ++ pkg/cli/delete_api.go | 67 ++ pkg/cli/delete_webhook.go | 67 ++ pkg/config/interface.go | 5 + pkg/config/v3/config.go | 78 +- pkg/config/v3/config_test.go | 83 ++ pkg/model/resource/webhooks.go | 34 + pkg/model/resource/webhooks_set_test.go | 99 ++ pkg/plugin/plugin.go | 23 + pkg/plugin/subcommand.go | 30 + pkg/plugins/common/kustomize/v2/delete_api.go | 41 + .../kustomize/v2/delete_integration_test.go | 153 +++ .../common/kustomize/v2/delete_webhook.go | 75 ++ pkg/plugins/common/kustomize/v2/plugin.go | 12 + .../kustomize/v2/scaffolds/delete_api.go | 195 ++++ .../kustomize/v2/scaffolds/delete_helpers.go | 99 ++ .../kustomize/v2/scaffolds/delete_webhook.go | 270 +++++ pkg/plugins/external/delete_api.go | 90 ++ pkg/plugins/external/delete_webhook.go | 90 ++ pkg/plugins/external/external_test.go | 20 + pkg/plugins/external/plugin.go | 22 +- pkg/plugins/external/webhook.go | 1 + .../golang/deploy-image/v1alpha1/api.go | 13 +- .../deploy-image/v1alpha1/delete_api.go | 101 ++ .../golang/deploy-image/v1alpha1/plugin.go | 9 +- pkg/plugins/golang/v4/delete_api.go | 956 +++++++++++++++ pkg/plugins/golang/v4/delete_api_test.go | 100 ++ .../golang/v4/delete_integration_test.go | 1033 +++++++++++++++++ pkg/plugins/golang/v4/delete_webhook.go | 632 ++++++++++ pkg/plugins/golang/v4/delete_webhook_test.go | 283 +++++ pkg/plugins/golang/v4/plugin.go | 16 +- .../v1alpha/delete_integration_test.go | 80 ++ .../optional/autoupdate/v1alpha/edit.go | 43 + .../optional/autoupdate/v1alpha/plugin.go | 8 +- .../v1alpha/delete_integration_test.go | 83 ++ pkg/plugins/optional/grafana/v1alpha/edit.go | 64 +- pkg/plugins/optional/grafana/v1alpha/init.go | 1 - .../optional/grafana/v1alpha/plugin.go | 8 +- .../optional/grafana/v1alpha/suite_test.go | 29 + .../helm/v2alpha/delete_integration_test.go | 101 ++ pkg/plugins/optional/helm/v2alpha/edit.go | 93 ++ pkg/plugins/optional/helm/v2alpha/plugin.go | 8 +- test/e2e/utils/test_context.go | 33 + 55 files changed, 5506 insertions(+), 27 deletions(-) create mode 100644 pkg/cli/delete.go create mode 100644 pkg/cli/delete_api.go create mode 100644 pkg/cli/delete_webhook.go create mode 100644 pkg/model/resource/webhooks_set_test.go create mode 100644 pkg/plugins/common/kustomize/v2/delete_api.go create mode 100644 pkg/plugins/common/kustomize/v2/delete_integration_test.go create mode 100644 pkg/plugins/common/kustomize/v2/delete_webhook.go create mode 100644 pkg/plugins/common/kustomize/v2/scaffolds/delete_api.go create mode 100644 pkg/plugins/common/kustomize/v2/scaffolds/delete_helpers.go create mode 100644 pkg/plugins/common/kustomize/v2/scaffolds/delete_webhook.go create mode 100644 pkg/plugins/external/delete_api.go create mode 100644 pkg/plugins/external/delete_webhook.go create mode 100644 pkg/plugins/golang/deploy-image/v1alpha1/delete_api.go create mode 100644 pkg/plugins/golang/v4/delete_api.go create mode 100644 pkg/plugins/golang/v4/delete_api_test.go create mode 100644 pkg/plugins/golang/v4/delete_integration_test.go create mode 100644 pkg/plugins/golang/v4/delete_webhook.go create mode 100644 pkg/plugins/golang/v4/delete_webhook_test.go create mode 100644 pkg/plugins/optional/autoupdate/v1alpha/delete_integration_test.go create mode 100644 pkg/plugins/optional/grafana/v1alpha/delete_integration_test.go create mode 100644 pkg/plugins/optional/grafana/v1alpha/suite_test.go create mode 100644 pkg/plugins/optional/helm/v2alpha/delete_integration_test.go diff --git a/.gitignore b/.gitignore index 2effa7bf1d7..f2949f788ff 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,4 @@ docs/book/src/docs ## Skip testdata files that generate by tests using TestContext **/e2e-*/** # Optional rendered chart output (e.g. from make yamllint-helm when debugging) -testdata/.helm-rendered.yaml \ No newline at end of file +testdata/.helm-rendered.yaml diff --git a/AGENTS.md b/AGENTS.md index d922741033a..383a623ce9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -199,9 +199,21 @@ Plugins implement interfaces from `pkg/plugin/`: - `Init` - project initialization (`kubebuilder init`) - `CreateAPI` - API creation (`kubebuilder create api`) - `CreateWebhook` - webhook creation (`kubebuilder create webhook`) +- `DeleteAPI` - API deletion (`kubebuilder delete api`) +- `DeleteWebhook` - webhook deletion (`kubebuilder delete webhook`) - `Edit` - post-init modifications (`kubebuilder edit`) - `Bundle` - groups multiple plugins +**Delete = Undo of Create:** + +Each plugin's delete implementation MUST undo exactly what its create implementation did: +- `go/v4`: Removes Go code (API types, controllers, main.go imports/setup, suite_test.go) +- `kustomize/v2`: Removes manifests (samples, RBAC, CRD kustomization entries) +- `deploy-image/v1-alpha`: Removes plugin metadata from PROJECT file +- When plugins run in chain (e.g., `--plugins deploy-image/v1-alpha`), both layout and additional plugins execute + +**Integration tests MUST verify**: `state_before_create == state_after_delete` + **Plugin Bundles:** Default bundle (`pkg/cli/init.go`): `go.kubebuilder.io/v4` + `kustomize.common.kubebuilder.io/v2` @@ -249,7 +261,8 @@ Controllers implement `Reconcile(ctx, req) (ctrl.Result, error)`: - **Requeue on pending work** - Return `ctrl.Result{Requeue: true}` ### Testing Pattern -E2E tests use `utils.TestContext` from `test/e2e/utils/test_context.go`: + +**Integration Tests** use `utils.TestContext` from `test/e2e/utils/test_context.go`: ```go ctx := utils.NewTestContext(util.KubebuilderBinName, "GO111MODULE=on") @@ -259,19 +272,70 @@ ctx.Make("build", "test") ctx.LoadImageToKindCluster() ``` +**Baseline Testing (Required for Delete):** + +Delete integration tests MUST verify exact state restoration: + +```go +It("should restore exact state after delete", func() { + mainBefore, _ := os.ReadFile("cmd/main.go") + ctx.CreateAPI(...) + ctx.DeleteAPI(..., "-y") + mainAfter, _ := os.ReadFile("cmd/main.go") + Expect(mainAfter).To(Equal(mainBefore)) // Exact match required +}) +``` + ## CLI Reference After `make install`: ```bash +# Initialize project kubebuilder init --domain example.com --repo github.com/example/myproject + +# Create resources kubebuilder create api --group batch --version v1 --kind CronJob kubebuilder create webhook --group batch --version v1 --kind CronJob -kubebuilder edit --plugins=helm/v2-alpha + +# Delete resources (complete undo of create) +kubebuilder delete api --group batch --version v1 --kind CronJob +kubebuilder delete webhook --group batch --version v1 --kind CronJob --defaulting + +# Delete with plugin chain +kubebuilder delete api --group app --version v1 --kind Cache --plugins deploy-image/v1-alpha + +# Delete optional plugin features +kubebuilder delete --plugins helm/v2-alpha +kubebuilder delete --plugins grafana/v1-alpha + +# Edit project +kubebuilder edit --plugins helm/v2-alpha + +# Alpha commands kubebuilder alpha generate # Experimental: generate from PROJECT file kubebuilder alpha update # Experimental: update to latest plugin versions ``` +## Implementing Delete + +**Rule**: If you add a `create` command, you MUST add the corresponding `delete` command. + +**Key Principle**: Each plugin undoes ONLY what it created. When plugins run in chain (default: `go/v4` + `kustomize/v2`), each cleans its own artifacts: +- `go/v4` → removes Go code (types, controllers, main.go, suite_test.go) +- `kustomize/v2` → removes manifests (samples, RBAC, CRD entries) +- Additional plugins → remove their metadata from PROJECT file + +**Shared Resources**: Imports/code used by multiple resources are preserved until the last one is deleted (e.g., `appv1` import kept while any app/v1 API exists). + +**Integration Test**: Add `delete_integration_test.go` with baseline verification: +```go +baseline := captureState() +createResource() +deleteResource("-y") +Expect(currentState()).To(Equal(baseline)) // Exact match required +``` + ## Common Patterns ### Code Style @@ -334,7 +398,7 @@ log.Error(err, "Failed to create Pod", "name", name) - **Integration tests** (`*_integration_test.go` in `pkg/`) - Test multiple components together without cluster - Must have `//go:build integration` tag at the top - May create temp dirs, download binaries, or scaffold files - - Examples: alpha update, grafana scaffolding, helm chart generation + - **Delete tests**: MUST use baseline pattern (verify before_create == after_delete) - **E2E tests** (`test/e2e/`) - **ONLY** for tests requiring a Kubernetes cluster (KIND) - `v4/plugin_cluster_test.go` - Test v4 plugin deployment - `helm/plugin_cluster_test.go` - Test Helm chart deployment diff --git a/docs/book/src/plugins/available/autoupdate-v1-alpha.md b/docs/book/src/plugins/available/autoupdate-v1-alpha.md index 2fd9cc313f9..c87c9f62409 100644 --- a/docs/book/src/plugins/available/autoupdate-v1-alpha.md +++ b/docs/book/src/plugins/available/autoupdate-v1-alpha.md @@ -38,6 +38,12 @@ kubebuilder edit --plugins="autoupdate/v1-alpha" kubebuilder init --plugins=go/v4,autoupdate/v1-alpha ``` +- To remove the auto-update workflow from your project: + +```shell +kubebuilder delete --plugins autoupdate/v1-alpha +``` + ### Optional: GitHub Models AI Summary By default, the workflow works without GitHub Models to avoid permission errors. diff --git a/docs/book/src/plugins/available/deploy-image-plugin-v1-alpha.md b/docs/book/src/plugins/available/deploy-image-plugin-v1-alpha.md index cfa88033e98..f96a80014c8 100644 --- a/docs/book/src/plugins/available/deploy-image-plugin-v1-alpha.md +++ b/docs/book/src/plugins/available/deploy-image-plugin-v1-alpha.md @@ -82,9 +82,25 @@ export MEMCACHED_IMAGE="memcached:1.4.36-alpine" ## Subcommands -The `deploy-image` plugin includes the following subcommand: +The `deploy-image` plugin implements: -- `create api`: Use this command to scaffold the API and controller code to manage the container image. +- `create api` - Scaffolds the API and controller code to manage the container image + +### Deleting APIs Created with Deploy-Image + +To delete an API created with deploy-image, you **must** include the plugin flag: + +```sh +kubebuilder delete api --group --version --kind \ + --plugins=deploy-image/v1-alpha +``` + +If you forget the `--plugins` flag, you'll receive an error message showing the exact command to use. + +The delete operation removes: +- API and controller files (via go/v4 plugin in the chain) +- Deploy-image metadata from the PROJECT file +- Kustomize manifests (samples, RBAC) ## Affected files diff --git a/docs/book/src/plugins/available/go-v4-plugin.md b/docs/book/src/plugins/available/go-v4-plugin.md index b16b21034bf..3abe2d96761 100644 --- a/docs/book/src/plugins/available/go-v4-plugin.md +++ b/docs/book/src/plugins/available/go-v4-plugin.md @@ -32,6 +32,8 @@ kubebuilder init --domain tutorial.kubebuilder.io --repo tutorial.kubebuilder.io - Edit - `kubebuilder edit [OPTIONS]` - Create API - `kubebuilder create api [OPTIONS]` - Create Webhook - `kubebuilder create webhook [OPTIONS]` +- Delete API - `kubebuilder delete api [OPTIONS]` +- Delete Webhook - `kubebuilder delete webhook [OPTIONS]` ## Further resources diff --git a/docs/book/src/plugins/available/grafana-v1-alpha.md b/docs/book/src/plugins/available/grafana-v1-alpha.md index d086fe17f09..5a74715a4fd 100644 --- a/docs/book/src/plugins/available/grafana-v1-alpha.md +++ b/docs/book/src/plugins/available/grafana-v1-alpha.md @@ -49,11 +49,24 @@ The Grafana plugin is attached to the `init` subcommand and the `edit` subcomman kubebuilder init --plugins grafana.kubebuilder.io/v1-alpha # Enable grafana plugin to an existing project -kubebuilder edit --plugins grafana.kubebuilder.io/v1-alpha +kubebuilder edit --plugins grafana/v1-alpha + +# Remove grafana dashboards from project +kubebuilder delete --plugins grafana/v1-alpha ``` The plugin will create a new directory and scaffold the JSON files under it (i.e. `grafana/controller-runtime-metrics.json`). +### Removing Grafana Dashboards + +To remove Grafana manifests from your project: + +```sh +kubebuilder delete --plugins grafana/v1-alpha +``` + +This removes the `grafana/` directory and all dashboard files. + #### Show case: See an example of how to use the plugin in your project: @@ -203,7 +216,7 @@ customMetrics: #### Scaffold Manifest -Once `config.yaml` is configured, you can run `kubebuilder edit --plugins grafana.kubebuilder.io/v1-alpha` again. +Once `config.yaml` is configured, you can run `kubebuilder edit --plugins grafana/v1-alpha` again. This time, the plugin will generate `grafana/custom-metrics/custom-metrics-dashboard.json`, which can be imported to Grafana UI. #### Show case: diff --git a/docs/book/src/plugins/available/helm-v2-alpha.md b/docs/book/src/plugins/available/helm-v2-alpha.md index 22c65f7228f..1d85f2811d2 100644 --- a/docs/book/src/plugins/available/helm-v2-alpha.md +++ b/docs/book/src/plugins/available/helm-v2-alpha.md @@ -74,6 +74,19 @@ kubebuilder edit --plugins=helm/v2-alpha \ --output-dir=helm-charts ``` +### Removing Helm Charts + +To remove the Helm chart from your project: + +```shell +kubebuilder delete --plugins helm/v2-alpha +``` + +This removes: +- `dist/chart/` directory +- `.github/workflows/test-chart.yml` +- Plugin configuration from PROJECT file + ## Chart Structure The plugin creates a chart layout that matches your `config/`: diff --git a/docs/book/src/plugins/available/kustomize-v2.md b/docs/book/src/plugins/available/kustomize-v2.md index 929b96457ed..087d0cece3e 100644 --- a/docs/book/src/plugins/available/kustomize-v2.md +++ b/docs/book/src/plugins/available/kustomize-v2.md @@ -70,14 +70,18 @@ The kustomize plugin implements the following subcommands: * init (`$ kubebuilder init [OPTIONS]`) * create api (`$ kubebuilder create api [OPTIONS]`) -* create webhook (`$ kubebuilder create api [OPTIONS]`) +* create webhook (`$ kubebuilder create webhook [OPTIONS]`) +* delete api (`$ kubebuilder delete api [OPTIONS]`) +* delete webhook (`$ kubebuilder delete webhook [OPTIONS]`) diff --git a/docs/book/src/plugins/extending/extending_cli_features_and_plugins.md b/docs/book/src/plugins/extending/extending_cli_features_and_plugins.md index 8b117ff5581..a7c79afd53a 100644 --- a/docs/book/src/plugins/extending/extending_cli_features_and_plugins.md +++ b/docs/book/src/plugins/extending/extending_cli_features_and_plugins.md @@ -62,7 +62,9 @@ of the following CLI commands: - `init`: Initializes the project structure. - `create api`: Scaffolds a new API and controller. - `create webhook`: Scaffolds a new webhook. -- `edit`: edit the project structure. +- `delete api`: Deletes an API and its associated files. +- `delete webhook`: Deletes a webhook and its associated files. +- `edit`: Updates the project structure. Here’s an example of using the `init` subcommand with a custom plugin: diff --git a/docs/book/src/plugins/extending/external-plugins.md b/docs/book/src/plugins/extending/external-plugins.md index 49126066620..14c3bc169a8 100644 --- a/docs/book/src/plugins/extending/external-plugins.md +++ b/docs/book/src/plugins/extending/external-plugins.md @@ -133,6 +133,8 @@ External plugins can support the following Kubebuilder subcommands: - `init`: Project initialization - `create api`: Scaffold Kubernetes API definitions - `create webhook`: Scaffold Kubernetes webhooks +- `delete api`: Delete Kubernetes API definitions and associated files +- `delete webhook`: Delete Kubernetes webhooks and associated files - `edit`: Update project configuration **Optional subcommands for enhanced user experience:** diff --git a/docs/book/src/plugins/plugins.md b/docs/book/src/plugins/plugins.md index 66dae4859b6..12a469bdb20 100644 --- a/docs/book/src/plugins/plugins.md +++ b/docs/book/src/plugins/plugins.md @@ -31,6 +31,10 @@ kubebuilder create api --plugins=pluginA,pluginB,pluginC OR kubebuilder create webhook --plugins=pluginA,pluginB,pluginC OR +kubebuilder delete api --plugins=pluginA,pluginB,pluginC +OR +kubebuilder delete webhook --plugins=pluginA,pluginB,pluginC +OR kubebuilder edit --plugins=pluginA,pluginB,pluginC ``` diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go index f5f71b70c26..c4ea4678c93 100644 --- a/pkg/cli/cli.go +++ b/pkg/cli/cli.go @@ -21,6 +21,7 @@ import ( "fmt" log "log/slog" "os" + "slices" "strings" "github.com/spf13/afero" @@ -373,6 +374,7 @@ func (c *CLI) getInfoFromFlags(hasConfigFile bool) error { } // If any plugin key was provided, replace those from the project configuration file + // Exception: for delete commands, APPEND to layout plugins instead of replacing if pluginKeys, err := fs.GetStringSlice(pluginsFlag); err != nil { return fmt.Errorf("invalid flag %q: %w", pluginsFlag, err) } else if len(pluginKeys) != 0 { @@ -407,7 +409,24 @@ func (c *CLI) getInfoFromFlags(hasConfigFile bool) error { } } - c.pluginKeys = validPluginKeys + // For delete commands, if we have layout plugins from PROJECT, keep them + // and append the user-specified plugins (avoiding duplicates). + // Extract the command name from positional args (after flags are parsed). + isDeleteCommand := c.isDeleteCommand(fs.Args()) + + if isDeleteCommand && len(c.pluginKeys) > 0 { + // Append user plugins to layout plugins (deduplicate) + combined := append([]string{}, c.pluginKeys...) + for _, userPlugin := range validPluginKeys { + found := slices.Contains(combined, userPlugin) + if !found { + combined = append(combined, userPlugin) + } + } + c.pluginKeys = combined + } else { + c.pluginKeys = validPluginKeys + } } // If the project version flag was accepted but not provided keep the empty version and try to resolve it later, @@ -421,6 +440,14 @@ func (c *CLI) getInfoFromFlags(hasConfigFile bool) error { return nil } +// isDeleteCommand checks if the command being executed is a delete command +// by examining the positional arguments (excluding flags). +func (c *CLI) isDeleteCommand(args []string) bool { + // Args contains positional arguments after pflag parsing (flags are removed). + // Check if the first positional argument is "delete". + return len(args) > 0 && args[0] == "delete" +} + // getInfoFromDefaults obtains the plugin keys, and maybe the project version from the default values func (c *CLI) getInfoFromDefaults() { // Should not use default values if a plugin was already set @@ -554,6 +581,15 @@ func (c *CLI) addSubcommands() { c.cmd.AddCommand(createCmd) } + // kubebuilder delete + deleteCmd := c.newDeleteCmd() + // kubebuilder delete api + deleteCmd.AddCommand(c.newDeleteAPICmd()) + deleteCmd.AddCommand(c.newDeleteWebhookCmd()) + if deleteCmd.HasSubCommands() { + c.cmd.AddCommand(deleteCmd) + } + // kubebuilder edit c.cmd.AddCommand(c.newEditCmd()) diff --git a/pkg/cli/delete.go b/pkg/cli/delete.go new file mode 100644 index 00000000000..7913c152fef --- /dev/null +++ b/pkg/cli/delete.go @@ -0,0 +1,93 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "sigs.k8s.io/kubebuilder/v4/pkg/plugin" +) + +func (c CLI) newDeleteCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a Kubernetes API, webhook, or plugin features", + Long: `Delete scaffolded code and manifests for APIs, webhooks, or plugin features. + +Deletes generated files and updates PROJECT configuration automatically. +Code inserted at markers in cmd/main.go is automatically removed when possible. + +Examples: + # Delete an API (webhooks must be deleted first) + kubebuilder delete api --group crew --version v1 --kind Captain + + # Delete specific webhook type + kubebuilder delete webhook --group crew --version v1 --kind Captain --defaulting + + # Delete all webhook types for a resource + kubebuilder delete webhook --group crew --version v1 --kind Captain + + # Delete API created with additional plugins + kubebuilder delete api --group crew --version v1 --kind Captain --plugins=deploy-image/v1-alpha + + # Delete optional plugin features + kubebuilder delete --plugins=grafana/v1-alpha + kubebuilder delete --plugins=helm/v2-alpha +`, + RunE: c.deleteWithPlugins, + } + + return cmd +} + +// deleteWithPlugins handles delete command when --plugins flag is used for optional plugins +func (c CLI) deleteWithPlugins(_ *cobra.Command, args []string) error { + if len(args) > 0 { + return fmt.Errorf("delete requires a subcommand: api or webhook") + } + + // Filter to ONLY plugins that explicitly support deletion via Edit interface + // (optional plugins like helm, grafana, autoupdate use Edit with --delete flag internally) + deletePlugins := []plugin.Plugin{} + for _, p := range c.resolvedPlugins { + if deleteSupportPlugin, ok := p.(plugin.HasDeleteSupport); ok && deleteSupportPlugin.SupportsDelete() { + deletePlugins = append(deletePlugins, p) + } + } + + if len(deletePlugins) == 0 { + return fmt.Errorf("delete requires a subcommand (api, webhook) or " + + "use with optional plugins (helm, grafana, autoupdate)") + } + + // Temporarily replace resolvedPlugins with ONLY delete-supporting plugins + // This ensures non-delete plugins in the chain don't receive the --delete flag + originalPlugins := c.resolvedPlugins + c.resolvedPlugins = deletePlugins + defer func() { c.resolvedPlugins = originalPlugins }() + + // Forward to edit command with --delete flag for optional plugin cleanup + editCmd := c.newEditCmd() + editCmd.SetArgs(append(args, "--delete")) + if err := editCmd.Execute(); err != nil { + return fmt.Errorf("failed to delete optional plugin features: %w", err) + } + + return nil +} diff --git a/pkg/cli/delete_api.go b/pkg/cli/delete_api.go new file mode 100644 index 00000000000..97e7bdcda6b --- /dev/null +++ b/pkg/cli/delete_api.go @@ -0,0 +1,67 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "sigs.k8s.io/kubebuilder/v4/pkg/plugin" +) + +const deleteAPIErrorMsg = "failed to delete API" + +func (c CLI) newDeleteAPICmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "api", + Short: "Delete a Kubernetes API", + Long: `Delete a Kubernetes API and its associated files. +`, + RunE: errCmdFunc( + fmt.Errorf("api subcommand requires an existing project"), + ), + } + + // In case no plugin was resolved, instead of failing the construction of the CLI, fail the execution of + // this subcommand. This allows the use of subcommands that do not require resolved plugins like help. + if len(c.resolvedPlugins) == 0 { + cmdErr(cmd, noResolvedPluginError{}) + return cmd + } + + // Obtain the plugin keys and subcommands from the plugins that implement plugin.DeleteAPI. + subcommands := c.filterSubcommands( + func(p plugin.Plugin) bool { + _, isValid := p.(plugin.DeleteAPI) + return isValid + }, + func(p plugin.Plugin) plugin.Subcommand { + return p.(plugin.DeleteAPI).GetDeleteAPISubcommand() + }, + ) + + // Verify that there is at least one remaining plugin. + if len(subcommands) == 0 { + cmdErr(cmd, noAvailablePluginError{"API deletion"}) + return cmd + } + + c.applySubcommandHooks(cmd, subcommands, deleteAPIErrorMsg, false) + + return cmd +} diff --git a/pkg/cli/delete_webhook.go b/pkg/cli/delete_webhook.go new file mode 100644 index 00000000000..2c3b063328f --- /dev/null +++ b/pkg/cli/delete_webhook.go @@ -0,0 +1,67 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "sigs.k8s.io/kubebuilder/v4/pkg/plugin" +) + +const deleteWebhookErrorMsg = "failed to delete webhook" + +func (c CLI) newDeleteWebhookCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "webhook", + Short: "Delete a webhook for an API resource", + Long: `Delete a webhook for an API resource and associated files. +`, + RunE: errCmdFunc( + fmt.Errorf("webhook subcommand requires an existing project"), + ), + } + + // In case no plugin was resolved, instead of failing the construction of the CLI, fail the execution of + // this subcommand. This allows the use of subcommands that do not require resolved plugins like help. + if len(c.resolvedPlugins) == 0 { + cmdErr(cmd, noResolvedPluginError{}) + return cmd + } + + // Obtain the plugin keys and subcommands from the plugins that implement plugin.DeleteWebhook. + subcommands := c.filterSubcommands( + func(p plugin.Plugin) bool { + _, isValid := p.(plugin.DeleteWebhook) + return isValid + }, + func(p plugin.Plugin) plugin.Subcommand { + return p.(plugin.DeleteWebhook).GetDeleteWebhookSubcommand() + }, + ) + + // Verify that there is at least one remaining plugin. + if len(subcommands) == 0 { + cmdErr(cmd, noAvailablePluginError{"webhook deletion"}) + return cmd + } + + c.applySubcommandHooks(cmd, subcommands, deleteWebhookErrorMsg, false) + + return cmd +} diff --git a/pkg/config/interface.go b/pkg/config/interface.go index 87a6ba5e6d8..f9bb80205a5 100644 --- a/pkg/config/interface.go +++ b/pkg/config/interface.go @@ -89,6 +89,11 @@ type Config interface { AddResource(res resource.Resource) error // UpdateResource adds the provided resource if it was not present, modifies it if it was already present. UpdateResource(res resource.Resource) error + // RemoveResource removes the resource matching the provided GVK from the config. + RemoveResource(gvk resource.GVK) error + // SetResourceWebhooks sets the webhook configuration for a resource, replacing existing configuration. + // Unlike UpdateResource which merges, this completely replaces the webhook config. + SetResourceWebhooks(gvk resource.GVK, webhooks *resource.Webhooks) error // HasGroup checks if the provided group is the same as any of the tracked resources. HasGroup(group string) bool diff --git a/pkg/config/v3/config.go b/pkg/config/v3/config.go index 95a69df7e3d..8a710f4ed99 100644 --- a/pkg/config/v3/config.go +++ b/pkg/config/v3/config.go @@ -18,6 +18,7 @@ package v3 import ( "fmt" + "reflect" "strings" "sigs.k8s.io/yaml" @@ -277,6 +278,48 @@ func (c *Cfg) UpdateResource(res resource.Resource) error { return nil } +// RemoveResource implements config.Config +func (c *Cfg) RemoveResource(gvk resource.GVK) error { + indexToRemove := -1 + for i, r := range c.Resources { + // Match by Group, Version, Kind (not domain, as core types may not have domain set) + if r.Group == gvk.Group && r.Version == gvk.Version && r.Kind == gvk.Kind { + indexToRemove = i + break + } + } + + if indexToRemove == -1 { + return fmt.Errorf("failed to remove resource: resource with GVK {%q %q %q} not found", + gvk.Group, gvk.Version, gvk.Kind) + } + + // Remove the resource by slicing around it + c.Resources = append(c.Resources[:indexToRemove], c.Resources[indexToRemove+1:]...) + return nil +} + +// SetResourceWebhooks implements config.Config +func (c *Cfg) SetResourceWebhooks(gvk resource.GVK, webhooks *resource.Webhooks) error { + for i, r := range c.Resources { + // Match by Group, Version, Kind (not domain) + if r.Group == gvk.Group && r.Version == gvk.Version && r.Kind == gvk.Kind { + if webhooks == nil { + c.Resources[i].Webhooks = nil + } else { + if c.Resources[i].Webhooks == nil { + c.Resources[i].Webhooks = &resource.Webhooks{} + } + c.Resources[i].Webhooks.Set(webhooks) + } + return nil + } + } + + return fmt.Errorf("failed to set webhooks: resource with GVK {%q %q %q} not found", + gvk.Group, gvk.Version, gvk.Kind) +} + // HasGroup implements config.Config func (c Cfg) HasGroup(group string) bool { // Return true if the target group is found in the tracked resources @@ -348,6 +391,19 @@ func (c Cfg) DecodePluginConfig(key string, configObj any) error { } // EncodePluginConfig will return an error if used on any project version < v3. +// +// Plugin Configuration Deletion: +// +// To remove a plugin's configuration from the PROJECT file, pass an anonymous empty struct (struct{}{}). +// This explicitly signals the intent to delete the plugin's configuration entry. +// +// Example: +// +// // Delete plugin configuration +// err := config.EncodePluginConfig("my-plugin.example.com/v1", struct{}{}) +// +// Note: Named empty structs or structs with omitempty fields that evaluate to empty +// will be stored as "{}" in the PROJECT file, not deleted. Only struct{}{} triggers deletion. func (c *Cfg) EncodePluginConfig(key string, configObj any) error { // Get object's bytes and set them under key in extra fields. b, err := yaml.Marshal(configObj) @@ -361,10 +417,30 @@ func (c *Cfg) EncodePluginConfig(key string, configObj any) error { if c.Plugins == nil { c.Plugins = make(map[string]pluginConfig) } - c.Plugins[key] = fields + // If fields is empty and configObj is an anonymous empty struct, + // delete the key from the plugins map (used for plugin deletion). + // Named structs with omitempty fields that evaluate to empty will still be stored as {}. + if len(fields) == 0 && isAnonymousEmptyStruct(configObj) { + delete(c.Plugins, key) + } else { + c.Plugins[key] = fields + } return nil } +// isAnonymousEmptyStruct checks if the given value is an anonymous empty struct (struct{}{}). +// +// This function is used by EncodePluginConfig to distinguish between: +// - struct{}{} → explicit deletion signal (returns true) +// - type MyConfig struct{} → empty named struct (returns false, will be stored as {}) +// - Structs with omitempty fields that evaluate to empty (returns false, will be stored as {}) +// +// Only anonymous empty structs (struct{}{}) trigger configuration deletion from the PROJECT file. +func isAnonymousEmptyStruct(v any) bool { + typ := reflect.TypeOf(v) + return typ.Kind() == reflect.Struct && typ.Name() == "" && typ.NumField() == 0 +} + // MarshalYAML implements config.Config func (c Cfg) MarshalYAML() ([]byte, error) { for i, r := range c.Resources { diff --git a/pkg/config/v3/config_test.go b/pkg/config/v3/config_test.go index 9eff1296fb6..96353d7f2c1 100644 --- a/pkg/config/v3/config_test.go +++ b/pkg/config/v3/config_test.go @@ -276,6 +276,89 @@ var _ = Describe("Cfg", func() { checkResource(c.Resources[0], resWithoutPlural) }) + It("RemoveResource should remove the resource if it exists", func() { + c.Resources = append(c.Resources, res) + l := len(c.Resources) + Expect(c.RemoveResource(res.GVK)).To(Succeed()) + Expect(c.Resources).To(HaveLen(l - 1)) + Expect(c.HasResource(res.GVK)).To(BeFalse()) + }) + + It("RemoveResource should return an error if the resource does not exist", func() { + Expect(c.RemoveResource(res.GVK)).NotTo(Succeed()) + }) + + It("RemoveResource should remove only the specified resource", func() { + res2 := resource.Resource{ + GVK: resource.GVK{ + Group: "group", + Version: "v1", + Kind: "OtherKind", + }, + } + c.Resources = append(c.Resources, res, res2) + Expect(c.Resources).To(HaveLen(2)) + + Expect(c.RemoveResource(res.GVK)).To(Succeed()) + Expect(c.Resources).To(HaveLen(1)) + Expect(c.HasResource(res.GVK)).To(BeFalse()) + Expect(c.HasResource(res2.GVK)).To(BeTrue()) + }) + + It("SetResourceWebhooks should update webhook configuration", func() { + resWithWebhooks := resource.Resource{ + GVK: resource.GVK{ + Group: "group", + Version: "v1", + Kind: "Kind", + }, + Webhooks: &resource.Webhooks{ + WebhookVersion: "v1", + Defaulting: true, + Validation: true, + }, + } + c.Resources = append(c.Resources, resWithWebhooks) + + // Update to remove defaulting webhook + newWebhooks := &resource.Webhooks{ + WebhookVersion: "v1", + Defaulting: false, + Validation: true, + } + Expect(c.SetResourceWebhooks(resWithWebhooks.GVK, newWebhooks)).To(Succeed()) + + updated, err := c.GetResource(resWithWebhooks.GVK) + Expect(err).NotTo(HaveOccurred()) + Expect(updated.Webhooks.Defaulting).To(BeFalse()) + Expect(updated.Webhooks.Validation).To(BeTrue()) + }) + + It("SetResourceWebhooks should clear webhooks when nil provided", func() { + resWithWebhooks := resource.Resource{ + GVK: resource.GVK{ + Group: "group", + Version: "v1", + Kind: "Kind", + }, + Webhooks: &resource.Webhooks{ + WebhookVersion: "v1", + Defaulting: true, + }, + } + c.Resources = append(c.Resources, resWithWebhooks) + + Expect(c.SetResourceWebhooks(resWithWebhooks.GVK, nil)).To(Succeed()) + + updated, err := c.GetResource(resWithWebhooks.GVK) + Expect(err).NotTo(HaveOccurred()) + Expect(updated.Webhooks).To(BeNil()) + }) + + It("SetResourceWebhooks should fail if resource not found", func() { + Expect(c.SetResourceWebhooks(res.GVK, nil)).NotTo(Succeed()) + }) + It("HasGroup should return false with no tracked resources", func() { Expect(c.HasGroup(res.Group)).To(BeFalse()) }) diff --git a/pkg/model/resource/webhooks.go b/pkg/model/resource/webhooks.go index ed94cdc28b2..f2675229539 100644 --- a/pkg/model/resource/webhooks.go +++ b/pkg/model/resource/webhooks.go @@ -152,3 +152,37 @@ func (webhooks *Webhooks) AddSpoke(version string) { } webhooks.Spoke = append(webhooks.Spoke, version) } + +// Set replaces the webhook configuration with the provided one. +// Unlike Update which merges/adds, Set completely replaces the configuration. +// This is useful for delete operations where fields need to be removed. +func (webhooks *Webhooks) Set(other *Webhooks) { + if other == nil { + // Clear all fields + webhooks.WebhookVersion = "" + webhooks.Defaulting = false + webhooks.Validation = false + webhooks.Conversion = false + webhooks.Spoke = nil + webhooks.DefaultingPath = "" + webhooks.ValidationPath = "" + return + } + + // Replace with other's values + webhooks.WebhookVersion = other.WebhookVersion + webhooks.Defaulting = other.Defaulting + webhooks.Validation = other.Validation + webhooks.Conversion = other.Conversion + + // Deep copy spoke versions + if len(other.Spoke) > 0 { + webhooks.Spoke = make([]string, len(other.Spoke)) + copy(webhooks.Spoke, other.Spoke) + } else { + webhooks.Spoke = nil + } + + webhooks.DefaultingPath = other.DefaultingPath + webhooks.ValidationPath = other.ValidationPath +} diff --git a/pkg/model/resource/webhooks_set_test.go b/pkg/model/resource/webhooks_set_test.go new file mode 100644 index 00000000000..f494c765041 --- /dev/null +++ b/pkg/model/resource/webhooks_set_test.go @@ -0,0 +1,99 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package resource + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Webhooks Set", func() { + var webhooks *Webhooks + + BeforeEach(func() { + webhooks = &Webhooks{ + WebhookVersion: "v1", + Defaulting: true, + Validation: true, + Conversion: false, + Spoke: []string{"v1beta1"}, + DefaultingPath: "/mutate", + ValidationPath: "/validate", + } + }) + + Context("Set", func() { + It("should completely replace webhooks with new configuration", func() { + newWebhooks := &Webhooks{ + WebhookVersion: "v1", + Defaulting: false, + Validation: true, + Conversion: true, + Spoke: []string{"v2"}, + } + + webhooks.Set(newWebhooks) + + Expect(webhooks.Defaulting).To(BeFalse()) + Expect(webhooks.Validation).To(BeTrue()) + Expect(webhooks.Conversion).To(BeTrue()) + Expect(webhooks.Spoke).To(Equal([]string{"v2"})) + Expect(webhooks.DefaultingPath).To(BeEmpty()) + Expect(webhooks.ValidationPath).To(BeEmpty()) + }) + + It("should clear all fields when nil is provided", func() { + webhooks.Set(nil) + + Expect(webhooks.WebhookVersion).To(BeEmpty()) + Expect(webhooks.Defaulting).To(BeFalse()) + Expect(webhooks.Validation).To(BeFalse()) + Expect(webhooks.Conversion).To(BeFalse()) + Expect(webhooks.Spoke).To(BeNil()) + Expect(webhooks.DefaultingPath).To(BeEmpty()) + Expect(webhooks.ValidationPath).To(BeEmpty()) + }) + + It("should handle empty spoke slices correctly", func() { + newWebhooks := &Webhooks{ + WebhookVersion: "v1", + Validation: true, + } + + webhooks.Set(newWebhooks) + + Expect(webhooks.Spoke).To(BeNil()) + }) + + It("should deep copy spoke versions", func() { + spoke := []string{"v1beta1", "v1beta2"} + newWebhooks := &Webhooks{ + WebhookVersion: "v1", + Defaulting: true, + Spoke: spoke, + } + + webhooks.Set(newWebhooks) + + // Modify original spoke slice + spoke[0] = "modified" + + // Webhook's spoke should not be affected + Expect(webhooks.Spoke[0]).To(Equal("v1beta1")) + }) + }) +}) diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go index 75f18ac6443..f23a215e3e3 100644 --- a/pkg/plugin/plugin.go +++ b/pkg/plugin/plugin.go @@ -76,6 +76,29 @@ type Edit interface { GetEditSubcommand() EditSubcommand } +// DeleteAPI is an interface for plugins that provide a `delete api` subcommand. +type DeleteAPI interface { + Plugin + // GetDeleteAPISubcommand returns the underlying DeleteAPISubcommand interface. + GetDeleteAPISubcommand() DeleteAPISubcommand +} + +// DeleteWebhook is an interface for plugins that provide a `delete webhook` subcommand. +type DeleteWebhook interface { + Plugin + // GetDeleteWebhookSubcommand returns the underlying DeleteWebhookSubcommand interface. + GetDeleteWebhookSubcommand() DeleteWebhookSubcommand +} + +// HasDeleteSupport is an interface for plugins that support deletion via the Edit interface +// (e.g., optional plugins like helm, grafana, autoupdate that use --delete flag). +// This explicitly marks plugins that handle deletion through Edit rather than DeleteAPI/DeleteWebhook. +type HasDeleteSupport interface { + Edit + // SupportsDelete returns true if the plugin supports deletion through its Edit subcommand. + SupportsDelete() bool +} + // Full is an interface for plugins that provide `init`, `create api`, `create webhook` and `edit` subcommands. type Full interface { Init diff --git a/pkg/plugin/subcommand.go b/pkg/plugin/subcommand.go index acf5f0d4552..c9b734bf29c 100644 --- a/pkg/plugin/subcommand.go +++ b/pkg/plugin/subcommand.go @@ -66,6 +66,24 @@ type HasPostScaffold interface { PostScaffold() error } +// HasPluginChain is an interface that implements the optional plugin chain injection method. +// This allows subcommands to receive the full chain of plugins being executed in the current command, +// enabling cross-plugin coordination and validation. +// +// The plugin chain is automatically injected by the CLI before subcommand execution. +// Plugins should implement this interface if they need to: +// - Validate that required companion plugins are present in the chain +// - Coordinate behavior with other plugins in the execution sequence +// - Check plugin execution order +type HasPluginChain interface { + // SetPluginChain injects the current plugin chain into the subcommand. + // The chain represents the ordered list of plugin keys being executed for this command. + // + // Example chain: + // ["go.kubebuilder.io/v4", "kustomize.common.kubebuilder.io/v2", "deploy-image.go.kubebuilder.io/v1-alpha"] + SetPluginChain(chain []string) +} + // Subcommand is a base interface for all subcommands. type Subcommand interface { Scaffolder @@ -88,6 +106,18 @@ type CreateWebhookSubcommand interface { RequiresResource } +// DeleteAPISubcommand is an interface that represents a `delete api` subcommand. +type DeleteAPISubcommand interface { + Subcommand + RequiresResource +} + +// DeleteWebhookSubcommand is an interface that represents a `delete webhook` subcommand. +type DeleteWebhookSubcommand interface { + Subcommand + RequiresResource +} + // EditSubcommand is an interface that represents an `edit` subcommand. type EditSubcommand interface { Subcommand diff --git a/pkg/plugins/common/kustomize/v2/delete_api.go b/pkg/plugins/common/kustomize/v2/delete_api.go new file mode 100644 index 00000000000..35003d8bda0 --- /dev/null +++ b/pkg/plugins/common/kustomize/v2/delete_api.go @@ -0,0 +1,41 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v2 + +import ( + "fmt" + + "sigs.k8s.io/kubebuilder/v4/pkg/machinery" + "sigs.k8s.io/kubebuilder/v4/pkg/plugin" + "sigs.k8s.io/kubebuilder/v4/pkg/plugins/common/kustomize/v2/scaffolds" +) + +var _ plugin.DeleteAPISubcommand = &deleteAPISubcommand{} + +type deleteAPISubcommand struct { + createSubcommand +} + +func (p *deleteAPISubcommand) Scaffold(fs machinery.Filesystem) error { + scaffolder := scaffolds.NewDeleteAPIScaffolder(p.config, *p.resource) + scaffolder.InjectFS(fs) + if err := scaffolder.Scaffold(); err != nil { + return fmt.Errorf("failed to clean up kustomize API files: %w", err) + } + + return nil +} diff --git a/pkg/plugins/common/kustomize/v2/delete_integration_test.go b/pkg/plugins/common/kustomize/v2/delete_integration_test.go new file mode 100644 index 00000000000..90baa47e84d --- /dev/null +++ b/pkg/plugins/common/kustomize/v2/delete_integration_test.go @@ -0,0 +1,153 @@ +// Copyright 2026 The Kubernetes Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build integration + +package v2 + +import ( + "fmt" + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "sigs.k8s.io/kubebuilder/v4/pkg/plugin/util" + "sigs.k8s.io/kubebuilder/v4/test/e2e/utils" +) + +var _ = Describe("Kustomize Delete Integration Tests", func() { + var kbc *utils.TestContext + + BeforeEach(func() { + var err error + kbc, err = utils.NewTestContext(util.KubebuilderBinName, "GO111MODULE=on") + Expect(err).NotTo(HaveOccurred()) + Expect(kbc.Prepare()).To(Succeed()) + + err = kbc.Init("--plugins", "go/v4", "--domain", kbc.Domain, "--repo", kbc.Domain, "--skip-go-version-check") + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + kbc.Destroy() + }) + + It("should test all kustomize file cleanup scenarios", func() { + By("verifying kustomize creates and deletes all expected files") + + By("verifying baseline is clean (kustomize files don't exist yet)") + Expect(kbc.HasFile("config/samples/kustomization.yaml")).To(BeFalse(), + "baseline should not have samples kustomization") + Expect(kbc.HasFile("config/crd/kustomization.yaml")).To(BeFalse(), + "baseline should not have CRD kustomization") + + // Create API and verify kustomize files + err := kbc.CreateAPI("--group", "test", "--version", "v1", "--kind", "Sample", + "--resource", "--controller", "--make=false") + Expect(err).NotTo(HaveOccurred()) + + // Verify kustomize files created + sampleFiles := []string{ + "config/samples/test_v1_sample.yaml", + "config/rbac/sample_admin_role.yaml", + "config/rbac/sample_editor_role.yaml", + "config/rbac/sample_viewer_role.yaml", + "config/crd/kustomization.yaml", + "config/samples/kustomization.yaml", + } + + for _, file := range sampleFiles { + Expect(kbc.HasFile(file)).To(BeTrue(), + fmt.Sprintf("kustomize file should be created: %s", file)) + } + + // Delete API and verify kustomize files deleted + err = kbc.DeleteAPI("--group", "test", "--version", "v1", "--kind", "Sample", + "-y") + Expect(err).NotTo(HaveOccurred()) + + for _, file := range sampleFiles { + Expect(kbc.HasFile(file)).To(BeFalse(), + fmt.Sprintf("kustomize file should be deleted: %s", file)) + } + + By("verifying all kustomization files are properly cleaned up") + + // Create multiple APIs to test selective kustomization entry removal + err = kbc.CreateAPI("--group", "test", "--version", "v1", "--kind", "First", + "--resource", "--controller", "--make=false") + Expect(err).NotTo(HaveOccurred()) + + err = kbc.CreateAPI("--group", "test", "--version", "v1", "--kind", "Second", + "--resource", "--controller", "--make=false") + Expect(err).NotTo(HaveOccurred()) + + By("verifying both APIs in kustomization files") + crdKustomize, err := os.ReadFile(filepath.Join(kbc.Dir, "config", "crd", "kustomization.yaml")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(crdKustomize)).To(ContainSubstring("test." + kbc.Domain + "_firsts.yaml")) + Expect(string(crdKustomize)).To(ContainSubstring("test." + kbc.Domain + "_seconds.yaml")) + + samplesKustomize, err := os.ReadFile(filepath.Join(kbc.Dir, "config", "samples", "kustomization.yaml")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(samplesKustomize)).To(ContainSubstring("test_v1_first.yaml")) + Expect(string(samplesKustomize)).To(ContainSubstring("test_v1_second.yaml")) + + By("deleting first API") + err = kbc.DeleteAPI("--group", "test", "--version", "v1", "--kind", "First", "-y") + Expect(err).NotTo(HaveOccurred()) + + By("verifying First removed but Second remains in kustomization files") + crdKustomize, err = os.ReadFile(filepath.Join(kbc.Dir, "config", "crd", "kustomization.yaml")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(crdKustomize)).NotTo(ContainSubstring("test."+kbc.Domain+"_firsts.yaml"), + "First should be removed from CRD kustomization") + Expect(string(crdKustomize)).To(ContainSubstring("test."+kbc.Domain+"_seconds.yaml"), + "Second should remain in CRD kustomization") + + samplesKustomize, err = os.ReadFile(filepath.Join(kbc.Dir, "config", "samples", "kustomization.yaml")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(samplesKustomize)).NotTo(ContainSubstring("test_v1_first.yaml"), + "First should be removed from samples kustomization") + Expect(string(samplesKustomize)).To(ContainSubstring("test_v1_second.yaml"), + "Second should remain in samples kustomization") + + rbacKustomize, err := os.ReadFile(filepath.Join(kbc.Dir, "config", "rbac", "kustomization.yaml")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(rbacKustomize)).NotTo(ContainSubstring("first_admin_role.yaml"), + "First RBAC should be removed") + Expect(string(rbacKustomize)).To(ContainSubstring("second_admin_role.yaml"), + "Second RBAC should remain") + + By("verifying kustomization files still exist (not last API)") + Expect(kbc.HasFile(filepath.Join("config", "crd", "kustomization.yaml"))).To(BeTrue(), + "CRD kustomization should exist when APIs remain") + Expect(kbc.HasFile(filepath.Join("config", "samples", "kustomization.yaml"))).To(BeTrue(), + "Samples kustomization should exist when APIs remain") + + By("deleting last API") + err = kbc.DeleteAPI("--group", "test", "--version", "v1", "--kind", "Second", "-y") + Expect(err).NotTo(HaveOccurred()) + + By("verifying kustomization files deleted with last API") + Expect(kbc.HasFile(filepath.Join("config", "crd", "kustomization.yaml"))).To(BeFalse(), + "CRD kustomization should be deleted with last API") + Expect(kbc.HasFile(filepath.Join("config", "crd", "kustomizeconfig.yaml"))).To(BeFalse(), + "CRD kustomizeconfig should be deleted with last API") + Expect(kbc.HasFile(filepath.Join("config", "samples", "kustomization.yaml"))).To(BeFalse(), + "Samples kustomization should be deleted with last API") + }) +}) diff --git a/pkg/plugins/common/kustomize/v2/delete_webhook.go b/pkg/plugins/common/kustomize/v2/delete_webhook.go new file mode 100644 index 00000000000..b80e8a86e15 --- /dev/null +++ b/pkg/plugins/common/kustomize/v2/delete_webhook.go @@ -0,0 +1,75 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v2 + +import ( + "fmt" + + "sigs.k8s.io/kubebuilder/v4/pkg/machinery" + "sigs.k8s.io/kubebuilder/v4/pkg/plugin" + "sigs.k8s.io/kubebuilder/v4/pkg/plugins/common/kustomize/v2/scaffolds" +) + +var _ plugin.DeleteWebhookSubcommand = &deleteWebhookSubcommand{} + +type deleteWebhookSubcommand struct { + createSubcommand + storedFS machinery.Filesystem + + // Track the before-state (before golang/v4 modifies config) + hadConversionBefore bool +} + +func (p *deleteWebhookSubcommand) Scaffold(fs machinery.Filesystem) error { + // Store filesystem for PostScaffold use + p.storedFS = fs + + // Capture the "before" state BEFORE golang/v4 modifies the config + // p.resource from CLI doesn't have full webhooks info, so read from config + beforeRes, err := p.config.GetResource(p.resource.GVK) + if err == nil && beforeRes.Webhooks != nil { + p.hadConversionBefore = beforeRes.Webhooks.Conversion + } + + return nil +} + +// PostScaffold performs kustomize cleanup after golang plugin updates config +func (p *deleteWebhookSubcommand) PostScaffold() error { + scaffolder := scaffolds.NewDeleteWebhookScaffolder(p.config, *p.resource) + scaffolder.InjectFS(p.storedFS) + + // Pass information about which webhook types were deleted + // Compare before-state (captured in Scaffold) with after-state (from config now) + currentRes, _ := p.config.GetResource(p.resource.GVK) + hasConversionAfter := currentRes.Webhooks != nil && currentRes.Webhooks.Conversion + deletedConversion := p.hadConversionBefore && !hasConversionAfter + + scaffolder.SetDeletedWebhookTypes(deletedConversion) + + // Now execute the actual cleanup (after golang/v4 updated config) + if err := scaffolder.Scaffold(); err != nil { + return fmt.Errorf("failed to clean up kustomize webhook files: %w", err) + } + + // Run PostScaffold for final steps + if err := scaffolder.PostScaffold(); err != nil { + return fmt.Errorf("failed to run post-scaffold cleanup: %w", err) + } + + return nil +} diff --git a/pkg/plugins/common/kustomize/v2/plugin.go b/pkg/plugins/common/kustomize/v2/plugin.go index c256469d254..8c9f8cc6e99 100644 --- a/pkg/plugins/common/kustomize/v2/plugin.go +++ b/pkg/plugins/common/kustomize/v2/plugin.go @@ -38,6 +38,8 @@ var ( _ plugin.Init = Plugin{} _ plugin.CreateAPI = Plugin{} _ plugin.CreateWebhook = Plugin{} + _ plugin.DeleteAPI = Plugin{} + _ plugin.DeleteWebhook = Plugin{} ) // Plugin implements the plugin.Full interface @@ -45,6 +47,8 @@ type Plugin struct { initSubcommand createAPISubcommand createWebhookSubcommand + deleteAPISubcommand + deleteWebhookSubcommand } // Name returns the name of the plugin @@ -67,6 +71,14 @@ func (p Plugin) GetCreateWebhookSubcommand() plugin.CreateWebhookSubcommand { return &p.createWebhookSubcommand } +// GetDeleteAPISubcommand will return the subcommand which is responsible for cleaning up API kustomize files +func (p Plugin) GetDeleteAPISubcommand() plugin.DeleteAPISubcommand { return &p.deleteAPISubcommand } + +// GetDeleteWebhookSubcommand will return the subcommand which is responsible for cleaning up webhook kustomize files +func (p Plugin) GetDeleteWebhookSubcommand() plugin.DeleteWebhookSubcommand { + return &p.deleteWebhookSubcommand +} + // Description returns a short description of the plugin func (Plugin) Description() string { return "Scaffolds base Kustomize configuration" diff --git a/pkg/plugins/common/kustomize/v2/scaffolds/delete_api.go b/pkg/plugins/common/kustomize/v2/scaffolds/delete_api.go new file mode 100644 index 00000000000..40174496c8d --- /dev/null +++ b/pkg/plugins/common/kustomize/v2/scaffolds/delete_api.go @@ -0,0 +1,195 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package scaffolds + +import ( + "fmt" + log "log/slog" + "path/filepath" + "strings" + + "sigs.k8s.io/kubebuilder/v4/pkg/config" + "sigs.k8s.io/kubebuilder/v4/pkg/machinery" + "sigs.k8s.io/kubebuilder/v4/pkg/model/resource" + "sigs.k8s.io/kubebuilder/v4/pkg/plugins" +) + +var _ plugins.Scaffolder = &deleteAPIScaffolder{} + +// deleteAPIScaffolder removes kustomize files created for an API +type deleteAPIScaffolder struct { + config config.Config + resource resource.Resource + fs machinery.Filesystem +} + +// NewDeleteAPIScaffolder returns a new scaffolder for API deletion operations +func NewDeleteAPIScaffolder(cfg config.Config, res resource.Resource) plugins.Scaffolder { + return &deleteAPIScaffolder{ + config: cfg, + resource: res, + } +} + +// InjectFS implements Scaffolder +func (s *deleteAPIScaffolder) InjectFS(fs machinery.Filesystem) { + s.fs = fs +} + +// Scaffold implements Scaffolder - deletes kustomize files for the API +func (s *deleteAPIScaffolder) Scaffold() error { + log.Info("Cleaning up kustomize API files...") + + kindLower := strings.ToLower(s.resource.Kind) + multigroup := s.config.IsMultiGroup() + + // Delete sample file + sampleFile := filepath.Join("config", "samples", + fmt.Sprintf("%s_%s_%s.yaml", s.resource.Group, s.resource.Version, kindLower)) + if err := removeFileIfExists(s.fs.FS, sampleFile); err != nil { + log.Warn("Failed to delete sample file", "file", sampleFile, "error", err) + } + + // Remove sample entry from config/samples/kustomization.yaml + s.removeSampleFromKustomization(sampleFile) + + // Delete RBAC files (best effort) + var rbacFiles []string + if multigroup && s.resource.Group != "" { + rbacFiles = []string{ + filepath.Join("config", "rbac", fmt.Sprintf("%s_%s_admin_role.yaml", s.resource.Group, kindLower)), + filepath.Join("config", "rbac", fmt.Sprintf("%s_%s_editor_role.yaml", s.resource.Group, kindLower)), + filepath.Join("config", "rbac", fmt.Sprintf("%s_%s_viewer_role.yaml", s.resource.Group, kindLower)), + } + } else { + rbacFiles = []string{ + filepath.Join("config", "rbac", fmt.Sprintf("%s_admin_role.yaml", kindLower)), + filepath.Join("config", "rbac", fmt.Sprintf("%s_editor_role.yaml", kindLower)), + filepath.Join("config", "rbac", fmt.Sprintf("%s_viewer_role.yaml", kindLower)), + } + } + + for _, rbacFile := range rbacFiles { + if err := removeFileIfExists(s.fs.FS, rbacFile); err != nil { + log.Warn("Failed to delete RBAC file", "file", rbacFile, "error", err) + } + } + + // Remove RBAC entries from config/rbac/kustomization.yaml + s.removeRBACFromKustomization(rbacFiles) + + // Remove CRD entry from config/crd/kustomization.yaml + s.removeCRDFromKustomization() + + // Check if this is the last API and clean up shared kustomize files + if s.isLastAPI() { + s.cleanupLastAPIKustomizeFiles() + } + + return nil +} + +// isLastAPI checks if this is the last API in the project +func (s *deleteAPIScaffolder) isLastAPI() bool { + resources, err := s.config.GetResources() + if err != nil { + return false + } + + for _, res := range resources { + if res.Group == s.resource.Group && res.Version == s.resource.Version && res.Kind == s.resource.Kind { + continue + } + if res.API != nil { + return false + } + } + + return true +} + +// removeSampleFromKustomization removes the sample entry from config/samples/kustomization.yaml +func (s *deleteAPIScaffolder) removeSampleFromKustomization(sampleFile string) { + kustomizationPath := filepath.Join("config", "samples", "kustomization.yaml") + + // Extract just the filename from the full path + _, filename := filepath.Split(sampleFile) + lineToRemove := fmt.Sprintf("- %s", filename) + + removed, err := removeLinesFromKustomization(s.fs.FS, kustomizationPath, []string{lineToRemove}) + if err != nil { + log.Warn("Failed to remove sample from kustomization", "file", kustomizationPath, "error", err) + } else if removed { + log.Info("Removed sample entry from kustomization", "file", kustomizationPath, "entry", lineToRemove) + } +} + +// removeRBACFromKustomization removes RBAC entries from config/rbac/kustomization.yaml +func (s *deleteAPIScaffolder) removeRBACFromKustomization(rbacFiles []string) { + kustomizationPath := filepath.Join("config", "rbac", "kustomization.yaml") + + linesToRemove := make([]string, 0, len(rbacFiles)) + for _, rbacFile := range rbacFiles { + // Extract just the filename from the full path + _, filename := filepath.Split(rbacFile) + linesToRemove = append(linesToRemove, fmt.Sprintf("- %s", filename)) + } + + removed, err := removeLinesFromKustomization(s.fs.FS, kustomizationPath, linesToRemove) + if err != nil { + log.Warn("Failed to remove RBAC from kustomization", "file", kustomizationPath, "error", err) + } else if removed { + log.Info("Removed RBAC entries from kustomization", "file", kustomizationPath) + } +} + +// removeCRDFromKustomization removes the CRD entry from config/crd/kustomization.yaml +func (s *deleteAPIScaffolder) removeCRDFromKustomization() { + kustomizationPath := filepath.Join("config", "crd", "kustomization.yaml") + + // Construct the CRD filename based on resource + // Format: bases/_.yaml + crdFile := fmt.Sprintf("bases/%s_%s.yaml", s.resource.QualifiedGroup(), s.resource.Plural) + lineToRemove := fmt.Sprintf("- %s", crdFile) + + removed, err := removeLinesFromKustomization(s.fs.FS, kustomizationPath, []string{lineToRemove}) + if err != nil { + log.Warn("Failed to remove CRD from kustomization", "file", kustomizationPath, "error", err) + } else if removed { + log.Info("Removed CRD entry from kustomization", "file", kustomizationPath, "entry", lineToRemove) + } +} + +// cleanupLastAPIKustomizeFiles removes CRD and sample kustomization files +// This is called ONLY when deleting the very last API in the project +func (s *deleteAPIScaffolder) cleanupLastAPIKustomizeFiles() { + log.Info("This is the last API - removing kustomization base files") + + kustomizeFiles := []string{ + filepath.Join("config", "crd", "kustomization.yaml"), + filepath.Join("config", "crd", "kustomizeconfig.yaml"), + filepath.Join("config", "samples", "kustomization.yaml"), + } + + for _, file := range kustomizeFiles { + if err := removeFileIfExists(s.fs.FS, file); err != nil { + log.Warn("Failed to delete kustomization file", "file", file, "error", err) + } else { + log.Info("Deleted kustomization file", "file", file) + } + } +} diff --git a/pkg/plugins/common/kustomize/v2/scaffolds/delete_helpers.go b/pkg/plugins/common/kustomize/v2/scaffolds/delete_helpers.go new file mode 100644 index 00000000000..dff1ea4d2cd --- /dev/null +++ b/pkg/plugins/common/kustomize/v2/scaffolds/delete_helpers.go @@ -0,0 +1,99 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package scaffolds + +import ( + "bufio" + "bytes" + "fmt" + log "log/slog" + + "github.com/spf13/afero" +) + +// removeFileIfExists removes a file if it exists (best effort helper) +// Used by delete scaffolders to clean up files +func removeFileIfExists(afs afero.Fs, path string) error { + exists, err := afero.Exists(afs, path) + if err != nil { + return fmt.Errorf("failed to check file: %w", err) + } + if !exists { + return nil // Not an error if file doesn't exist + } + + if err := afs.Remove(path); err != nil { + return fmt.Errorf("failed to remove file: %w", err) + } + + log.Info("Deleted file", "file", path) + return nil +} + +// removeLinesFromKustomization removes specific lines from a kustomization file +// Returns true if any lines were found and removed, false if none found +func removeLinesFromKustomization(afs afero.Fs, filePath string, linesToRemove []string) (bool, error) { + exists, err := afero.Exists(afs, filePath) + if err != nil { + return false, fmt.Errorf("failed to check file: %w", err) + } + if !exists { + return false, nil + } + + content, err := afero.ReadFile(afs, filePath) + if err != nil { + return false, fmt.Errorf("failed to read file: %w", err) + } + + // Build set of exact lines to remove (preserving indentation) + removeSet := make(map[string]bool) + for _, line := range linesToRemove { + removeSet[line] = true + } + + // Read file line by line and skip target lines + scanner := bufio.NewScanner(bytes.NewReader(content)) + var out bytes.Buffer + removed := false + + for scanner.Scan() { + line := scanner.Text() + // Compare exact line (including indentation) + if removeSet[line] { + removed = true + continue // Skip this line + } + out.WriteString(line) + out.WriteString("\n") + } + + if err := scanner.Err(); err != nil { + return false, fmt.Errorf("failed to scan file: %w", err) + } + + if !removed { + return false, nil + } + + // Write the modified content back + if err := afero.WriteFile(afs, filePath, out.Bytes(), 0o644); err != nil { + return false, fmt.Errorf("failed to write file: %w", err) + } + + return true, nil +} diff --git a/pkg/plugins/common/kustomize/v2/scaffolds/delete_webhook.go b/pkg/plugins/common/kustomize/v2/scaffolds/delete_webhook.go new file mode 100644 index 00000000000..eedf45078f6 --- /dev/null +++ b/pkg/plugins/common/kustomize/v2/scaffolds/delete_webhook.go @@ -0,0 +1,270 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package scaffolds + +import ( + "fmt" + log "log/slog" + "path/filepath" + + "github.com/spf13/afero" + + "sigs.k8s.io/kubebuilder/v4/pkg/config" + "sigs.k8s.io/kubebuilder/v4/pkg/machinery" + "sigs.k8s.io/kubebuilder/v4/pkg/model/resource" + "sigs.k8s.io/kubebuilder/v4/pkg/plugin/util" + "sigs.k8s.io/kubebuilder/v4/pkg/plugins" +) + +var _ plugins.Scaffolder = &deleteWebhookScaffolder{} + +// deleteWebhookScaffolder removes kustomize files created for webhooks +type deleteWebhookScaffolder struct { + config config.Config + resource resource.Resource + fs machinery.Filesystem + + // Track which webhook types were deleted (to remove conversion patch) + deletedConversion bool +} + +// NewDeleteWebhookScaffolder returns a new scaffolder for webhook deletion operations +func NewDeleteWebhookScaffolder(cfg config.Config, res resource.Resource) *DeleteWebhookScaffolder { + return &DeleteWebhookScaffolder{ + config: cfg, + resource: res, + } +} + +// DeleteWebhookScaffolder is the exported type +type DeleteWebhookScaffolder = deleteWebhookScaffolder + +// SetDeletedWebhookTypes sets which webhook types were deleted +func (s *deleteWebhookScaffolder) SetDeletedWebhookTypes(conversion bool) { + s.deletedConversion = conversion +} + +// InjectFS implements Scaffolder +func (s *deleteWebhookScaffolder) InjectFS(fs machinery.Filesystem) { + s.fs = fs +} + +// Scaffold implements Scaffolder +func (s *deleteWebhookScaffolder) Scaffold() error { + return nil +} + +// PostScaffold cleans up kustomize files after golang plugin has updated config +func (s *deleteWebhookScaffolder) PostScaffold() error { + log.Info("Checking kustomize webhook cleanup...") + + // If conversion webhook was deleted, clean up the webhook patch entry + if s.deletedConversion { + s.removeConversionWebhookPatch() + + // Check if there are still conversion webhooks in the project + // If not, comment out the configurations section (only conversion webhooks need it uncommented) + if !s.hasAnyConversionWebhookRemaining() { + s.commentOutCRDKustomizeConfigurations() + } + } + + // Now check if this is the last webhook (after golang/v4 updated the config) + isLastWebhook := s.isLastWebhookInProject() + + log.Info("Checking if last webhook", "isLast", isLastWebhook) + + if isLastWebhook { + log.Info("This is the last webhook in the project, cleaning up all webhook kustomize files...") + s.cleanupAllWebhookKustomizeFiles() + s.commentWebhookKustomizeConfiguration() + } else { + log.Info("Other webhooks still exist, skipping full cleanup") + } + + return nil +} + +// isLastWebhookInProject checks if there are any webhooks remaining in the project +func (s *deleteWebhookScaffolder) isLastWebhookInProject() bool { + resources, err := s.config.GetResources() + if err != nil { + return false + } + + for _, res := range resources { + if res.Webhooks != nil && !res.Webhooks.IsEmpty() { + return false + } + } + + return true +} + +// hasAnyConversionWebhookRemaining checks if any conversion webhooks still exist in the project +func (s *deleteWebhookScaffolder) hasAnyConversionWebhookRemaining() bool { + resources, err := s.config.GetResources() + if err != nil { + return false + } + + for _, res := range resources { + if res.Webhooks != nil && res.Webhooks.Conversion { + return true + } + } + + return false +} + +// commentOutCRDKustomizeConfigurations comments out the configurations section in config/crd/kustomization.yaml +// This should be called when the last conversion webhook is deleted (conversion webhooks need this uncommented) +func (s *deleteWebhookScaffolder) commentOutCRDKustomizeConfigurations() { + crdKustomizePath := filepath.Join("config", "crd", "kustomization.yaml") + + // Use exact replacement to preserve newlines precisely + uncommented := `configurations: +- kustomizeconfig.yaml +` + commented := `#configurations: +#- kustomizeconfig.yaml +` + + if err := util.ReplaceInFile(crdKustomizePath, uncommented, commented); err != nil { + log.Warn("Unable to comment out configurations section in CRD kustomization", + "file", crdKustomizePath, "error", err) + } else { + log.Info("Commented out configurations section in CRD kustomization (no conversion webhooks remain)", + "file", crdKustomizePath) + } +} + +// removeConversionWebhookPatch removes the conversion webhook patch entry from config/crd/kustomization.yaml +func (s *deleteWebhookScaffolder) removeConversionWebhookPatch() { + kustomizationPath := filepath.Join("config", "crd", "kustomization.yaml") + + // Determine the suffix based on multigroup + multigroup := s.config.IsMultiGroup() + suffix := s.resource.Plural + if multigroup && s.resource.Group != "" { + suffix = s.resource.Group + "_" + s.resource.Plural + } + + // The patch entry format: - path: patches/webhook_in_.yaml + patchEntry := fmt.Sprintf("- path: patches/webhook_in_%s.yaml", suffix) + + removed, err := removeLinesFromKustomization(s.fs.FS, kustomizationPath, []string{patchEntry}) + if err != nil { + log.Warn("Failed to remove conversion webhook patch from kustomization", + "file", kustomizationPath, "error", err) + } else if removed { + log.Info("Removed conversion webhook patch entry from kustomization", + "file", kustomizationPath, "entry", patchEntry) + + // Also delete the actual patch file + patchFile := filepath.Join("config", "crd", "patches", fmt.Sprintf("webhook_in_%s.yaml", suffix)) + if err := removeFileIfExists(s.fs.FS, patchFile); err != nil { + log.Warn("Failed to delete conversion webhook patch file", "file", patchFile, "error", err) + } + } +} + +// cleanupAllWebhookKustomizeFiles removes all webhook-related kustomize files +func (s *deleteWebhookScaffolder) cleanupAllWebhookKustomizeFiles() { + filesToDelete := []string{ + filepath.Join("config", "certmanager", "certificate.yaml"), + filepath.Join("config", "certmanager", "certificate-webhook.yaml"), + filepath.Join("config", "certmanager", "certificate-metrics.yaml"), + filepath.Join("config", "certmanager", "issuer.yaml"), + filepath.Join("config", "certmanager", "kustomization.yaml"), + filepath.Join("config", "certmanager", "kustomizeconfig.yaml"), + filepath.Join("config", "webhook", "kustomization.yaml"), + filepath.Join("config", "webhook", "service.yaml"), + filepath.Join("config", "default", "manager_webhook_patch.yaml"), + filepath.Join("config", "network-policy", "allow-webhook-traffic.yaml"), + } + + for _, file := range filesToDelete { + if err := removeFileIfExists(s.fs.FS, file); err != nil { + log.Warn("Failed to delete webhook kustomize file", "file", file, "error", err) + } + } + + // Delete directories if they're now empty or force delete them + dirsToDelete := []string{ + filepath.Join("config", "certmanager"), + filepath.Join("config", "webhook"), + } + + for _, dir := range dirsToDelete { + if exists, _ := afero.DirExists(s.fs.FS, dir); exists { + if err := s.fs.FS.RemoveAll(dir); err != nil { + log.Warn("Failed to delete directory", "dir", dir, "error", err) + } else { + log.Info("Deleted directory", "dir", dir) + } + } + } +} + +// commentWebhookKustomizeConfiguration comments out webhook-related kustomize configuration +func (s *deleteWebhookScaffolder) commentWebhookKustomizeConfiguration() { + kustomizeFilePath := filepath.Join("config", "default", "kustomization.yaml") + + // Comment out ../webhook directory reference + if err := util.CommentCode(kustomizeFilePath, "- ../webhook", "#"); err != nil { + log.Warn("Unable to comment out '../webhook' in kustomization.yaml", + "file", kustomizeFilePath, "error", err) + } + + // Comment out ../certmanager directory reference + if err := util.CommentCode(kustomizeFilePath, "- ../certmanager", "#"); err != nil { + log.Warn("Unable to comment out '../certmanager' in kustomization.yaml", + "file", kustomizeFilePath, "error", err) + } + + // Comment out patches section header (best effort) + if err := util.CommentCode(kustomizeFilePath, "patches:", "#"); err != nil { + log.Warn("Unable to comment out 'patches:' section", + "file", kustomizeFilePath, "error", err) + } + + // Comment out manager webhook patch + managerPatchBlock := `- path: manager_webhook_patch.yaml + target: + kind: Deployment` + if err := util.CommentCode(kustomizeFilePath, managerPatchBlock, "#"); err != nil { + log.Warn("Unable to comment out manager_webhook_patch.yaml", + "file", kustomizeFilePath, "error", err) + } + + // Comment out replacements section (best effort) + if err := util.CommentCode(kustomizeFilePath, "replacements:", "#"); err != nil { + log.Warn("Unable to comment out 'replacements:' section", + "file", kustomizeFilePath, "error", err) + } + + // Remove webhook traffic line from network policy + networkPolicyPath := filepath.Join("config", "network-policy", "kustomization.yaml") + if err := util.ReplaceInFile(networkPolicyPath, "- allow-webhook-traffic.yaml\n", ""); err != nil { + log.Warn("Unable to remove 'allow-webhook-traffic.yaml' from network policy", + "file", networkPolicyPath, "error", err) + } + + // Note: CRD kustomization configurations section is handled separately in commentOutCRDKustomizeConfigurations() + // when the last conversion webhook is deleted (called from PostScaffold when deletedConversion is true) +} diff --git a/pkg/plugins/external/delete_api.go b/pkg/plugins/external/delete_api.go new file mode 100644 index 00000000000..7a3cd8ceb9a --- /dev/null +++ b/pkg/plugins/external/delete_api.go @@ -0,0 +1,90 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//nolint:dupl +package external + +import ( + "github.com/spf13/pflag" + + "sigs.k8s.io/kubebuilder/v4/pkg/config" + "sigs.k8s.io/kubebuilder/v4/pkg/machinery" + "sigs.k8s.io/kubebuilder/v4/pkg/model/resource" + "sigs.k8s.io/kubebuilder/v4/pkg/plugin" + "sigs.k8s.io/kubebuilder/v4/pkg/plugin/external" +) + +var _ plugin.DeleteAPISubcommand = &deleteAPISubcommand{} + +type deleteAPISubcommand struct { + Path string + Args []string + pluginChain []string + config config.Config +} + +// InjectConfig injects the project configuration so external plugins can read the PROJECT file. +func (p *deleteAPISubcommand) InjectConfig(c config.Config) error { + p.config = c + + if c == nil { + return nil + } + + if chain := c.GetPluginChain(); len(chain) > 0 { + p.pluginChain = append([]string(nil), chain...) + } + + return nil +} + +func (p *deleteAPISubcommand) SetPluginChain(chain []string) { + if len(chain) == 0 { + p.pluginChain = nil + return + } + + p.pluginChain = append([]string(nil), chain...) +} + +func (p *deleteAPISubcommand) InjectResource(*resource.Resource) error { + // Do nothing since resource flags are passed to the external plugin directly. + return nil +} + +func (p *deleteAPISubcommand) UpdateMetadata(_ plugin.CLIMetadata, subcmdMeta *plugin.SubcommandMetadata) { + setExternalPluginMetadata("delete api", p.Path, subcmdMeta) +} + +func (p *deleteAPISubcommand) BindFlags(fs *pflag.FlagSet) { + bindExternalPluginFlags(fs, "delete api", p.Path, p.Args) +} + +func (p *deleteAPISubcommand) Scaffold(fs machinery.Filesystem) error { + req := external.PluginRequest{ + APIVersion: defaultAPIVersion, + Command: "delete api", + Args: p.Args, + PluginChain: p.pluginChain, + } + + err := handlePluginResponse(fs, req, p.Path, p.config) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/plugins/external/delete_webhook.go b/pkg/plugins/external/delete_webhook.go new file mode 100644 index 00000000000..b31a451670e --- /dev/null +++ b/pkg/plugins/external/delete_webhook.go @@ -0,0 +1,90 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//nolint:dupl +package external + +import ( + "github.com/spf13/pflag" + + "sigs.k8s.io/kubebuilder/v4/pkg/config" + "sigs.k8s.io/kubebuilder/v4/pkg/machinery" + "sigs.k8s.io/kubebuilder/v4/pkg/model/resource" + "sigs.k8s.io/kubebuilder/v4/pkg/plugin" + "sigs.k8s.io/kubebuilder/v4/pkg/plugin/external" +) + +var _ plugin.DeleteWebhookSubcommand = &deleteWebhookSubcommand{} + +type deleteWebhookSubcommand struct { + Path string + Args []string + pluginChain []string + config config.Config +} + +// InjectConfig injects the project configuration so external plugins can read the PROJECT file. +func (p *deleteWebhookSubcommand) InjectConfig(c config.Config) error { + p.config = c + + if c == nil { + return nil + } + + if chain := c.GetPluginChain(); len(chain) > 0 { + p.pluginChain = append([]string(nil), chain...) + } + + return nil +} + +func (p *deleteWebhookSubcommand) SetPluginChain(chain []string) { + if len(chain) == 0 { + p.pluginChain = nil + return + } + + p.pluginChain = append([]string(nil), chain...) +} + +func (p *deleteWebhookSubcommand) InjectResource(*resource.Resource) error { + // Do nothing since resource flags are passed to the external plugin directly. + return nil +} + +func (p *deleteWebhookSubcommand) UpdateMetadata(_ plugin.CLIMetadata, subcmdMeta *plugin.SubcommandMetadata) { + setExternalPluginMetadata("delete webhook", p.Path, subcmdMeta) +} + +func (p *deleteWebhookSubcommand) BindFlags(fs *pflag.FlagSet) { + bindExternalPluginFlags(fs, "delete webhook", p.Path, p.Args) +} + +func (p *deleteWebhookSubcommand) Scaffold(fs machinery.Filesystem) error { + req := external.PluginRequest{ + APIVersion: defaultAPIVersion, + Command: "delete webhook", + Args: p.Args, + PluginChain: p.pluginChain, + } + + err := handlePluginResponse(fs, req, p.Path, p.config) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/plugins/external/external_test.go b/pkg/plugins/external/external_test.go index 55acf977a1f..cfb8bf7fb90 100644 --- a/pkg/plugins/external/external_test.go +++ b/pkg/plugins/external/external_test.go @@ -276,6 +276,26 @@ var _ = Describe("Run external plugin using Scaffold", func() { err = c.Scaffold(fs) Expect(err).ToNot(HaveOccurred()) }) + + It("should successfully run delete api subcommand on the external plugin", func() { + d := deleteAPISubcommand{ + Path: pluginFileName, + Args: args, + } + + err = d.Scaffold(fs) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should successfully run delete webhook subcommand on the external plugin", func() { + d := deleteWebhookSubcommand{ + Path: pluginFileName, + Args: args, + } + + err = d.Scaffold(fs) + Expect(err).ToNot(HaveOccurred()) + }) }) Context("with invalid mock values of GetExecOutput() and GetCurrentDir()", func() { diff --git a/pkg/plugins/external/plugin.go b/pkg/plugins/external/plugin.go index e5f8ec3f69a..a08ec650048 100644 --- a/pkg/plugins/external/plugin.go +++ b/pkg/plugins/external/plugin.go @@ -21,7 +21,11 @@ import ( "sigs.k8s.io/kubebuilder/v4/pkg/plugin" ) -var _ plugin.Full = Plugin{} +var ( + _ plugin.Full = Plugin{} + _ plugin.DeleteAPI = Plugin{} + _ plugin.DeleteWebhook = Plugin{} +) // Plugin implements the plugin.Full interface type Plugin struct { @@ -74,6 +78,22 @@ func (p Plugin) GetEditSubcommand() plugin.EditSubcommand { } } +// GetDeleteAPISubcommand will return the subcommand which is responsible for deleting apis +func (p Plugin) GetDeleteAPISubcommand() plugin.DeleteAPISubcommand { + return &deleteAPISubcommand{ + Path: p.Path, + Args: p.Args, + } +} + +// GetDeleteWebhookSubcommand will return the subcommand which is responsible for deleting webhooks +func (p Plugin) GetDeleteWebhookSubcommand() plugin.DeleteWebhookSubcommand { + return &deleteWebhookSubcommand{ + Path: p.Path, + Args: p.Args, + } +} + // DeprecationWarning define the deprecation message or return empty when plugin is not deprecated func (p Plugin) DeprecationWarning() string { return "" diff --git a/pkg/plugins/external/webhook.go b/pkg/plugins/external/webhook.go index 77c539c9f16..f4a8ba2b22e 100644 --- a/pkg/plugins/external/webhook.go +++ b/pkg/plugins/external/webhook.go @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +//nolint:dupl package external import ( diff --git a/pkg/plugins/golang/deploy-image/v1alpha1/api.go b/pkg/plugins/golang/deploy-image/v1alpha1/api.go index 85283fc06ba..51c1066f933 100644 --- a/pkg/plugins/golang/deploy-image/v1alpha1/api.go +++ b/pkg/plugins/golang/deploy-image/v1alpha1/api.go @@ -115,12 +115,19 @@ func (p *createAPISubcommand) BindFlags(fs *pflag.FlagSet) { "the controller and its spec in the API (CRD/CR). (i.e --image-container-port=\"11211\") ") fs.StringVar(&p.runAsUser, "run-as-user", "", "User-Id for the container formed will be set to this value") - fs.BoolVar(&p.runMake, "make", true, "if true, run `make generate` after generating files") - fs.BoolVar(&p.runManifests, "manifests", true, "if true, run `make manifests` after generating files") + // Bind make flags only if not already defined (defensive for plugin chaining) + if fs.Lookup("make") == nil { + fs.BoolVar(&p.runMake, "make", true, "if true, run `make generate` after generating files") + } + if fs.Lookup("manifests") == nil { + fs.BoolVar(&p.runManifests, "manifests", true, "if true, run `make manifests` after generating files") + } p.options = &goPlugin.Options{} - fs.StringVar(&p.options.Plural, "plural", "", "resource irregular plural form") + if fs.Lookup("plural") == nil { + fs.StringVar(&p.options.Plural, "plural", "", "resource irregular plural form") + } } func (p *createAPISubcommand) InjectConfig(c config.Config) error { diff --git a/pkg/plugins/golang/deploy-image/v1alpha1/delete_api.go b/pkg/plugins/golang/deploy-image/v1alpha1/delete_api.go new file mode 100644 index 00000000000..97ab89eea78 --- /dev/null +++ b/pkg/plugins/golang/deploy-image/v1alpha1/delete_api.go @@ -0,0 +1,101 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + "errors" + "fmt" + + "github.com/spf13/pflag" + + "sigs.k8s.io/kubebuilder/v4/pkg/config" + "sigs.k8s.io/kubebuilder/v4/pkg/machinery" + "sigs.k8s.io/kubebuilder/v4/pkg/model/resource" + "sigs.k8s.io/kubebuilder/v4/pkg/plugin" +) + +var _ plugin.DeleteAPISubcommand = &deleteAPISubcommand{} + +type deleteAPISubcommand struct { + config config.Config + resource *resource.Resource + + // assumeYes skips confirmation - bound for consistency when used in chain + assumeYes bool +} + +func (p *deleteAPISubcommand) UpdateMetadata(_ plugin.CLIMetadata, _ *plugin.SubcommandMetadata) { + // This plugin works in chain with go/v4 - go/v4 provides the user-facing metadata +} + +func (p *deleteAPISubcommand) BindFlags(fs *pflag.FlagSet) { + // Check if flag already exists (when used in chain with go/v4) + if fs.Lookup("yes") == nil { + fs.BoolVarP(&p.assumeYes, "yes", "y", false, + "proceed without prompting for confirmation") + } +} + +func (p *deleteAPISubcommand) InjectConfig(c config.Config) error { + p.config = c + return nil +} + +func (p *deleteAPISubcommand) InjectResource(res *resource.Resource) error { + p.resource = res + return nil +} + +func (p *deleteAPISubcommand) Scaffold(_ machinery.Filesystem) error { + canonicalKey := plugin.KeyFor(Plugin{}) + cfg := PluginConfig{} + + if err := p.config.DecodePluginConfig(canonicalKey, &cfg); err != nil { + // Plugin config doesn't exist or unsupported - nothing to clean up + var notFoundErr config.PluginKeyNotFoundError + var unsupportedErr config.UnsupportedFieldError + if errors.As(err, ¬FoundErr) || errors.As(err, &unsupportedErr) { + return nil + } + return fmt.Errorf("error decoding plugin configuration: %w", err) + } + + // Remove the resource from the plugin config + newResources := []ResourceData{} + for _, res := range cfg.Resources { + if res.Group == p.resource.Group && res.Version == p.resource.Version && res.Kind == p.resource.Kind { + continue // Skip the resource being deleted + } + newResources = append(newResources, res) + } + + // If no resources remain, remove the entire plugin config + // Otherwise update with the remaining resources + if len(newResources) == 0 { + // Remove plugin config entirely by encoding empty struct + if err := p.config.EncodePluginConfig(canonicalKey, struct{}{}); err != nil { + return fmt.Errorf("error removing plugin configuration: %w", err) + } + } else { + cfg.Resources = newResources + if err := p.config.EncodePluginConfig(canonicalKey, cfg); err != nil { + return fmt.Errorf("error encoding plugin configuration: %w", err) + } + } + + return nil +} diff --git a/pkg/plugins/golang/deploy-image/v1alpha1/plugin.go b/pkg/plugins/golang/deploy-image/v1alpha1/plugin.go index 8152e247eb6..82ca4b2c801 100644 --- a/pkg/plugins/golang/deploy-image/v1alpha1/plugin.go +++ b/pkg/plugins/golang/deploy-image/v1alpha1/plugin.go @@ -31,11 +31,15 @@ var ( supportedProjectVersions = []config.Version{cfgv3.Version} ) -var _ plugin.CreateAPI = Plugin{} +var ( + _ plugin.CreateAPI = Plugin{} + _ plugin.DeleteAPI = Plugin{} +) // Plugin implements the plugin.Full interface type Plugin struct { createAPISubcommand + deleteAPISubcommand } // Name returns the name of the plugin @@ -50,6 +54,9 @@ func (Plugin) SupportedProjectVersions() []config.Version { return supportedProj // GetCreateAPISubcommand will return the subcommand which is responsible for scaffolding apis func (p Plugin) GetCreateAPISubcommand() plugin.CreateAPISubcommand { return &p.createAPISubcommand } +// GetDeleteAPISubcommand will return the subcommand which is responsible for cleaning up plugin metadata +func (p Plugin) GetDeleteAPISubcommand() plugin.DeleteAPISubcommand { return &p.deleteAPISubcommand } + // PluginConfig defines the structure that will be used to track the data type PluginConfig struct { Resources []ResourceData `json:"resources,omitempty"` diff --git a/pkg/plugins/golang/v4/delete_api.go b/pkg/plugins/golang/v4/delete_api.go new file mode 100644 index 00000000000..474bece6793 --- /dev/null +++ b/pkg/plugins/golang/v4/delete_api.go @@ -0,0 +1,956 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v4 + +import ( + "bufio" + "bytes" + "fmt" + "go/ast" + "go/format" + "go/parser" + "go/token" + log "log/slog" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/spf13/afero" + "github.com/spf13/pflag" + + "sigs.k8s.io/kubebuilder/v4/pkg/config" + "sigs.k8s.io/kubebuilder/v4/pkg/machinery" + "sigs.k8s.io/kubebuilder/v4/pkg/model/resource" + "sigs.k8s.io/kubebuilder/v4/pkg/plugin" + "sigs.k8s.io/kubebuilder/v4/pkg/plugin/util" + goPlugin "sigs.k8s.io/kubebuilder/v4/pkg/plugins/golang" +) + +var _ plugin.DeleteAPISubcommand = &deleteAPISubcommand{} + +type deleteAPISubcommand struct { + config config.Config + // For help text. + commandName string + + options *goPlugin.Options + + resource *resource.Resource + + // assumeYes skips interactive confirmation prompts + assumeYes bool + + // pluginChain stores the current plugin chain for cross-plugin coordination + pluginChain []string + + // Track what couldn't be automatically removed for manual cleanup instructions + manualCleanupAPIImport bool + manualCleanupAddToScheme bool + manualCleanupControllerImport bool + manualCleanupControllerSetup bool + manualCleanupSuiteTestImport bool + manualCleanupSuiteTestScheme bool +} + +func (p *deleteAPISubcommand) UpdateMetadata(cliMeta plugin.CLIMetadata, subcmdMeta *plugin.SubcommandMetadata) { + p.commandName = cliMeta.CommandName + + subcmdMeta.Description = `Delete a Kubernetes API and its associated files. + +Automatically removes: +- API type definitions (api//_types.go) +- Controller files (internal/controller/_controller.go) +- Test files +- Kustomize manifests (samples, RBAC) +- Code from cmd/main.go and suite_test.go (imports, AddToScheme, controller setup) +- PROJECT file entries + +Constraints: +- Cannot delete an API while webhooks exist. Delete webhooks first. +- If created with additional plugins (e.g., deploy-image), must use --plugins flag with full chain. + +Manual cleanup shown if automatic code removal fails. +` + subcmdMeta.Examples = fmt.Sprintf( + ` # Delete the API for the Memcached kind + %[1]s delete api --group cache --version v1alpha1 --kind Memcached + + # Delete without confirmation prompt (use with caution) + %[1]s delete api --group cache --version v1alpha1 --kind Memcached -y + %[1]s delete api --group cache --version v1alpha1 --kind Memcached --yes +`, cliMeta.CommandName) +} + +func (p *deleteAPISubcommand) BindFlags(fs *pflag.FlagSet) { + if p.options == nil { + p.options = &goPlugin.Options{} + } + + fs.BoolVarP(&p.assumeYes, "yes", "y", false, + "proceed without prompting for confirmation") +} + +func (p *deleteAPISubcommand) InjectConfig(c config.Config) error { + p.config = c + return nil +} + +func (p *deleteAPISubcommand) InjectResource(res *resource.Resource) error { + p.resource = res + + // For core/external types, we need to match by GVK only, not domain + // Try to find the resource in config + var configRes resource.Resource + var found bool + + resources, err := p.config.GetResources() + if err != nil { + return fmt.Errorf("failed to get resources: %w", err) + } + + for _, r := range resources { + if r.Group == p.resource.Group && r.Version == p.resource.Version && r.Kind == p.resource.Kind { + configRes = r + found = true + break + } + } + + if !found { + return fmt.Errorf("resource {%q %q %q} does not exist in the project", + p.resource.Group, p.resource.Version, p.resource.Kind) + } + + // Check if the resource has webhooks - cannot delete API if webhooks exist + if configRes.Webhooks != nil && !configRes.Webhooks.IsEmpty() { + return fmt.Errorf("cannot delete API %q: webhooks are configured for this resource. "+ + "Please delete the webhooks first using 'kubebuilder delete webhook'", p.resource.GVK) + } + + // Check if resource was created with additional plugins (only if those plugins aren't in the current chain) + if err := p.checkAdditionalPlugins(); err != nil { + return err + } + + // Copy relevant fields from config resource + p.resource = &configRes + + return nil +} + +// SetPluginChain sets the plugin chain for cross-plugin coordination +func (p *deleteAPISubcommand) SetPluginChain(chain []string) { + // Store for checking if required plugins are present + p.pluginChain = chain +} + +// checkAdditionalPlugins detects if this resource was created with plugins beyond the default layout +// (e.g., deploy-image) and ensures those plugins are included in the deletion command. +// This guarantees complete cleanup of both files and plugin-specific metadata. +func (p *deleteAPISubcommand) checkAdditionalPlugins() error { + // Generic structure to check if any plugin stores metadata for this resource + type pluginResourceConfig struct { + Resources []struct { + Group string `json:"group,omitempty"` + Version string `json:"version"` + Kind string `json:"kind"` + } `json:"resources,omitempty"` + } + + // Known plugins that store resource-specific metadata in PROJECT file + // New plugins should be added here if they track resources + pluginsToCheck := []string{ + "deploy-image.go.kubebuilder.io/v1-alpha", + } + + // Find which plugins have metadata for this resource + requiredPlugins := []string{} + for _, pluginKey := range pluginsToCheck { + cfg := pluginResourceConfig{} + if err := p.config.DecodePluginConfig(pluginKey, &cfg); err != nil { + continue // Plugin not used in this project + } + + for _, res := range cfg.Resources { + if res.Group == p.resource.Group && res.Version == p.resource.Version && res.Kind == p.resource.Kind { + requiredPlugins = append(requiredPlugins, pluginKey) + break + } + } + } + + if len(requiredPlugins) == 0 { + return nil // No additional plugins used + } + + // Check if all required plugins are in the current command's plugin chain + // If layout plugins are missing (user only specified additional plugin), that's expected + // and we allow it - the layout plugins were already resolved by the CLI + missingPlugins := []string{} + for _, reqPlugin := range requiredPlugins { + if !slices.Contains(p.pluginChain, reqPlugin) { + missingPlugins = append(missingPlugins, reqPlugin) + } + } + + if len(missingPlugins) > 0 { + // Plugins are missing but this is OK if they're non-layout plugins + // Example: user runs `kubebuilder delete api --plugins deploy-image/v1-alpha` + // The layout plugin (go/v4) is already resolved by default, deploy-image is the extra + log.Info("Additional plugins detected for this resource", + "plugins", requiredPlugins, "chain", p.pluginChain) + } + + return nil +} + +func (p *deleteAPISubcommand) Scaffold(fs machinery.Filesystem) error { + // Prompt for confirmation unless -y/--yes flag provided + if !p.assumeYes { + if !p.confirmDeletion() { + return fmt.Errorf("deletion cancelled by user") + } + } + + log.Info("Deleting API files...") + + multigroup := p.config.IsMultiGroup() + + // Build list of files to delete + filesToDelete := []string{} + + // API types file + kindLower := strings.ToLower(p.resource.Kind) + var apiTypesPath string + if multigroup && p.resource.Group != "" { + apiTypesPath = filepath.Join("api", p.resource.Group, p.resource.Version, + fmt.Sprintf("%s_types.go", kindLower)) + } else { + apiTypesPath = filepath.Join("api", p.resource.Version, + fmt.Sprintf("%s_types.go", kindLower)) + } + filesToDelete = append(filesToDelete, apiTypesPath) + + // Controller files + var controllerPath, controllerTestPath, controllerSuiteTestPath string + if multigroup && p.resource.Group != "" { + controllerPath = filepath.Join("internal", "controller", p.resource.Group, + fmt.Sprintf("%s_controller.go", kindLower)) + controllerTestPath = filepath.Join("internal", "controller", p.resource.Group, + fmt.Sprintf("%s_controller_test.go", kindLower)) + controllerSuiteTestPath = filepath.Join("internal", "controller", p.resource.Group, + "suite_test.go") + } else { + controllerPath = filepath.Join("internal", "controller", + fmt.Sprintf("%s_controller.go", kindLower)) + controllerTestPath = filepath.Join("internal", "controller", + fmt.Sprintf("%s_controller_test.go", kindLower)) + controllerSuiteTestPath = filepath.Join("internal", "controller", + "suite_test.go") + } + + if p.resource.HasController() { + filesToDelete = append(filesToDelete, controllerPath, controllerTestPath) + + // Delete suite_test.go if this is the last controller in this group/version + if p.isLastControllerInGroup() { + filesToDelete = append(filesToDelete, controllerSuiteTestPath) + } + } + + // Delete the files + deletedFiles := []string{} + failedFiles := []string{} + + for _, file := range filesToDelete { + exists, err := afero.Exists(fs.FS, file) + if err != nil { + log.Warn("Error checking file existence", "file", file, "error", err) + continue + } + + if !exists { + log.Warn("File does not exist, skipping", "file", file) + continue + } + + if err := fs.FS.Remove(file); err != nil { + log.Error("Failed to delete file", "file", file, "error", err) + failedFiles = append(failedFiles, file) + } else { + log.Info("Deleted file", "file", file) + deletedFiles = append(deletedFiles, file) + } + } + + // Remove the resource from the PROJECT file + if err := p.config.RemoveResource(p.resource.GVK); err != nil { + return fmt.Errorf("failed to remove resource from PROJECT file: %w", err) + } + + // Clean up shared golang files (kustomize files handled by kustomize plugin) + additionalDeleted := p.cleanupSharedAPIFiles(fs) + deletedFiles = append(deletedFiles, additionalDeleted...) + + // Clean up empty multigroup directories + if multigroup && p.resource.Group != "" { + p.cleanupEmptyGroupDirectories(fs) + } + + // Remove marker-based code from main.go and test files + p.removeCodeFromMainGo(fs) + p.removeCodeFromSuiteTest(fs) + + // Report results + if len(failedFiles) > 0 { + return fmt.Errorf("failed to delete some files: %v", failedFiles) + } + + fmt.Printf("\nSuccessfully deleted API %s/%s (%s)\n", + p.resource.Group, p.resource.Version, p.resource.Kind) + fmt.Printf("Deleted %d file(s)\n", len(deletedFiles)) + + return nil +} + +func (p *deleteAPISubcommand) PostScaffold() error { + // Check if any manual cleanup is needed + needsManualCleanup := p.manualCleanupAPIImport || + p.manualCleanupAddToScheme || + p.manualCleanupControllerImport || + p.manualCleanupControllerSetup || + p.manualCleanupSuiteTestImport || + p.manualCleanupSuiteTestScheme + + if needsManualCleanup { + fmt.Println() + fmt.Println("Manual cleanup required - automatic removal failed for:") + + importAlias := strings.ToLower(p.resource.Group) + p.resource.Version + repo := p.config.GetRepository() + + if p.manualCleanupAPIImport || p.manualCleanupAddToScheme || + p.manualCleanupControllerImport || p.manualCleanupControllerSetup { + fmt.Println() + fmt.Println("In cmd/main.go:") + } + + if p.manualCleanupAPIImport { + if p.config.IsMultiGroup() && p.resource.Group != "" { + fmt.Printf(" - Remove import: %s \"%s/api/%s/%s\"\n", + importAlias, repo, p.resource.Group, p.resource.Version) + } else { + fmt.Printf(" - Remove import: %s \"%s/api/%s\"\n", + importAlias, repo, p.resource.Version) + } + } + + if p.manualCleanupAddToScheme { + fmt.Printf(" - Remove from init(): utilruntime.Must(%s.AddToScheme(scheme))\n", importAlias) + } + + if p.manualCleanupControllerImport { + if p.config.IsMultiGroup() && p.resource.Group != "" { + fmt.Printf(" - Remove import: %scontroller \"%s/internal/controller/%s\"\n", + strings.ToLower(p.resource.Group), repo, p.resource.Group) + } else { + fmt.Printf(" - Remove import: \"%s/internal/controller\"\n", repo) + } + } + + if p.manualCleanupControllerSetup { + fmt.Printf(" - Remove controller setup block for: %sReconciler (see constant reconcilerSetupCodeFragment)\n", + p.resource.Kind) + } + + if p.manualCleanupSuiteTestImport || p.manualCleanupSuiteTestScheme { + suiteTestPath := "internal/controller/suite_test.go" + if p.config.IsMultiGroup() && p.resource.Group != "" { + suiteTestPath = fmt.Sprintf("internal/controller/%s/suite_test.go", p.resource.Group) + } + fmt.Printf("\nIn %s:\n", suiteTestPath) + } + + if p.manualCleanupSuiteTestImport { + if p.config.IsMultiGroup() && p.resource.Group != "" { + fmt.Printf(" - Remove import: %s \"%s/api/%s/%s\"\n", + importAlias, repo, p.resource.Group, p.resource.Version) + } else { + fmt.Printf(" - Remove import: %s \"%s/api/%s\"\n", + importAlias, repo, p.resource.Version) + } + } + + if p.manualCleanupSuiteTestScheme { + fmt.Printf(" - Remove from BeforeSuite: err = %s.AddToScheme(scheme.Scheme)\n", importAlias) + fmt.Printf(" Expect(err).NotTo(HaveOccurred())\n") + } + } + + fmt.Println() + fmt.Println("Next steps:") + fmt.Println("$ go mod tidy") + fmt.Println("$ make generate") + fmt.Println("$ make manifests") + + return nil +} + +// Code fragments to remove from main.go (match the format used in create) +const ( + apiImportCodeFragment = `%s "%s" +` + controllerImportCodeFragment = `"%s/internal/controller" +` + multiGroupControllerImportCodeFragment = `%scontroller "%s/internal/controller/%s" +` + addSchemeCodeFragment = `utilruntime.Must(%s.AddToScheme(scheme)) +` + reconcilerSetupCodeFragment = `if err := (&controller.%sReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "Failed to create controller", "controller", "%s") + os.Exit(1) + } +` + multiGroupReconcilerSetupCodeFragment = `if err := (&%scontroller.%sReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "Failed to create controller", "controller", "%s") + os.Exit(1) + } +` +) + +// removeControllerSetupFlexible removes controller setup using AST parsing +// This handles plugin variations (e.g., deploy-image adds Recorder field) robustly +func (p *deleteAPISubcommand) removeControllerSetupFlexible(mainPath string) error { + content, err := os.ReadFile(mainPath) + if err != nil { + return fmt.Errorf("failed to read main.go: %w", err) + } + + // Determine the reconciler type name to search for + var reconcilerType string + if p.config.IsMultiGroup() && p.resource.Group != "" { + reconcilerType = fmt.Sprintf("%scontroller.%sReconciler", strings.ToLower(p.resource.Group), p.resource.Kind) + } else { + reconcilerType = fmt.Sprintf("controller.%sReconciler", p.resource.Kind) + } + + // Parse the file into an AST + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, mainPath, content, parser.ParseComments) + if err != nil { + return fmt.Errorf("failed to parse main.go: %w", err) + } + + // Find and remove the controller setup if statement + var found bool + ast.Inspect(file, func(n ast.Node) bool { + // Look for if statements + ifStmt, ok := n.(*ast.IfStmt) + if !ok { + return true + } + + // Check if this is the controller setup if statement + // Pattern: if err := (&controller.XReconciler{...}).SetupWithManager(mgr); err != nil { ... } + if p.isControllerSetupStmt(ifStmt, reconcilerType) { + // Remove this statement from the parent function + if err := p.removeStmtFromAST(file, ifStmt); err == nil { + found = true + } + return false + } + + return true + }) + + if !found { + return fmt.Errorf("controller setup block not found for %s", p.resource.Kind) + } + + // Format and write the modified AST back to the file + var buf bytes.Buffer + if err := format.Node(&buf, fset, file); err != nil { + return fmt.Errorf("failed to format modified code: %w", err) + } + + // Post-process to remove excessive blank lines before marker comments + // The Go formatter sometimes adds extra blank lines after removing statements + formattedCode := buf.String() + formattedCode = strings.ReplaceAll(formattedCode, "}\n\n\t// +kubebuilder:scaffold:builder", + "}\n\t// +kubebuilder:scaffold:builder") + + if err := os.WriteFile(mainPath, []byte(formattedCode), 0o644); err != nil { + return fmt.Errorf("failed to write main.go: %w", err) + } + + return nil +} + +// isControllerSetupStmt checks if the given if statement is a controller setup block +func (p *deleteAPISubcommand) isControllerSetupStmt(ifStmt *ast.IfStmt, reconcilerType string) bool { + // Check if init is an assignment (err := ...) + assignStmt, ok := ifStmt.Init.(*ast.AssignStmt) + if !ok || len(assignStmt.Lhs) != 1 || len(assignStmt.Rhs) != 1 { + return false + } + + // Check if the RHS is a method call (.SetupWithManager) + callExpr, ok := assignStmt.Rhs[0].(*ast.CallExpr) + if !ok { + return false + } + + // Check if it's a selector expression (something.SetupWithManager) + selExpr, ok := callExpr.Fun.(*ast.SelectorExpr) + if !ok || selExpr.Sel.Name != "SetupWithManager" { + return false + } + + // Convert the expression to string and check if it contains the reconciler type + exprStr := formatExprToString(assignStmt.Rhs[0]) + return strings.Contains(exprStr, reconcilerType) +} + +// removeStmtFromAST removes a statement from its parent function in the AST +func (p *deleteAPISubcommand) removeStmtFromAST(file *ast.File, target ast.Stmt) error { + var removed bool + + // Find the function containing this statement and remove it + ast.Inspect(file, func(n ast.Node) bool { + funcDecl, ok := n.(*ast.FuncDecl) + if !ok || funcDecl.Body == nil { + return true + } + + // Look through the function's statements + newStmts := make([]ast.Stmt, 0, len(funcDecl.Body.List)) + for _, stmt := range funcDecl.Body.List { + if stmt != target { + newStmts = append(newStmts, stmt) + } else { + removed = true + } + } + + if removed { + funcDecl.Body.List = newStmts + return false + } + + return true + }) + + if !removed { + return fmt.Errorf("failed to remove statement from AST") + } + + return nil +} + +// formatExprToString converts an expression to a string for pattern matching +func formatExprToString(expr ast.Expr) string { + var buf bytes.Buffer + fset := token.NewFileSet() + if err := format.Node(&buf, fset, expr); err != nil { + return "" + } + return buf.String() +} + +// removeCodeFromMainGo removes marker-inserted code from cmd/main.go +func (p *deleteAPISubcommand) removeCodeFromMainGo(_ machinery.Filesystem) { + mainPath := filepath.Join("cmd", "main.go") + repo := p.config.GetRepository() + + if p.resource.HasAPI() { + p.removeAPIImportAndScheme(mainPath, repo) + } + + if p.resource.HasController() { + p.removeControllerImportAndSetup(mainPath, repo) + } +} + +// removeAPIImportAndScheme removes API import and AddToScheme from main.go +func (p *deleteAPISubcommand) removeAPIImportAndScheme(mainPath, repo string) { + importAlias := strings.ToLower(p.resource.Group) + p.resource.Version + + // Check if any other resources share the same group+version + hasOtherResourcesSameVersion := false + resources, err := p.config.GetResources() + if err == nil { + for _, res := range resources { + if res.Group == p.resource.Group && res.Version == p.resource.Version && res.Kind != p.resource.Kind { + hasOtherResourcesSameVersion = true + break + } + } + } + + if hasOtherResourcesSameVersion { + log.Info("Other APIs share the same version, keeping shared import and AddToScheme", + "version", p.resource.Version, "alias", importAlias) + return + } + + // Build import path + var importPath string + if p.config.IsMultiGroup() && p.resource.Group != "" { + importPath = fmt.Sprintf("%s/api/%s/%s", repo, p.resource.Group, p.resource.Version) + } else { + importPath = fmt.Sprintf("%s/api/%s", repo, p.resource.Version) + } + + // Check if this is the last API import (to remove preceding blank line) + isLastAPIImport := true + for _, res := range resources { + // Skip the resource we're deleting + if res.Group == p.resource.Group && res.Version == p.resource.Version && res.Kind == p.resource.Kind { + continue + } + // Check if any other resource has API + if res.HasAPI() { + isLastAPIImport = false + break + } + } + + importToRemove := fmt.Sprintf("\t"+apiImportCodeFragment, importAlias, importPath) + if isLastAPIImport { + importToRemove = "\n" + importToRemove + } + + if err := util.ReplaceInFile(mainPath, importToRemove, ""); err != nil { + log.Warn("Unable to remove API import from main.go - manual cleanup needed", + "import", importAlias, "error", err) + p.manualCleanupAPIImport = true + } else { + log.Info("Removed API import from main.go", "import", importAlias) + } + + // Remove AddToScheme + schemeToRemove := fmt.Sprintf("\t"+addSchemeCodeFragment, importAlias) + if err := util.ReplaceInFile(mainPath, schemeToRemove, ""); err != nil { + log.Warn("Unable to remove AddToScheme from main.go - manual cleanup needed", + "code", fmt.Sprintf("utilruntime.Must(%s.AddToScheme(scheme))", importAlias)) + p.manualCleanupAddToScheme = true + } else { + log.Info("Removed AddToScheme from main.go") + } +} + +// removeControllerImportAndSetup removes controller import and setup from main.go +func (p *deleteAPISubcommand) removeControllerImportAndSetup(mainPath, repo string) { + // Check if other controllers exist in the same scope + hasOtherControllersInScope := false + allResources, err := p.config.GetResources() + if err == nil { + for _, res := range allResources { + if res.Group == p.resource.Group && res.Version == p.resource.Version && res.Kind == p.resource.Kind { + continue + } + if res.HasController() { + if !p.config.IsMultiGroup() || p.resource.Group == "" || res.Group == p.resource.Group { + hasOtherControllersInScope = true + break + } + } + } + } + + // Remove controller import only if no other controllers share it + if !hasOtherControllersInScope { + var controllerImport string + if p.config.IsMultiGroup() && p.resource.Group != "" { + controllerImport = fmt.Sprintf(multiGroupControllerImportCodeFragment, + strings.ToLower(p.resource.Group), repo, p.resource.Group) + } else { + controllerImport = fmt.Sprintf(controllerImportCodeFragment, repo) + } + + if err := util.ReplaceInFile(mainPath, "\t"+controllerImport, ""); err != nil { + log.Warn("Unable to remove controller import from main.go - manual cleanup needed") + p.manualCleanupControllerImport = true + } else { + log.Info("Removed controller import from main.go") + } + } else { + log.Info("Other controllers exist in the same scope, keeping shared controller import") + } + + // Always remove the specific controller setup code + p.removeControllerSetup(mainPath) +} + +// removeControllerSetup removes controller setup block from main.go +func (p *deleteAPISubcommand) removeControllerSetup(mainPath string) { + var controllerSetup string + if p.config.IsMultiGroup() && p.resource.Group != "" { + controllerSetup = fmt.Sprintf(multiGroupReconcilerSetupCodeFragment, + strings.ToLower(p.resource.Group), p.resource.Kind, p.resource.Kind) + } else { + controllerSetup = fmt.Sprintf(reconcilerSetupCodeFragment, + p.resource.Kind, p.resource.Kind) + } + + if err := util.ReplaceInFile(mainPath, "\t"+controllerSetup, ""); err == nil { + log.Info("Removed controller setup from main.go", "controller", p.resource.Kind) + } else if err := p.removeControllerSetupFlexible(mainPath); err != nil { + log.Warn("Unable to remove controller setup from main.go - manual cleanup needed", + "controller", p.resource.Kind, "error", err) + p.manualCleanupControllerSetup = true + } else { + log.Info("Removed controller setup from main.go (flexible match)", "controller", p.resource.Kind) + } +} + +// Code fragments for suite_test.go removal +const ( + suiteTestImportCodeFragment = `%s "%s" +` + suiteTestAddSchemeCodeFragment = `err = %s.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + +` +) + +// removeCodeFromSuiteTest removes marker-inserted code from suite_test.go +func (p *deleteAPISubcommand) removeCodeFromSuiteTest(_ machinery.Filesystem) { + if !p.resource.HasController() { + return + } + + var suiteTestPath string + if p.config.IsMultiGroup() && p.resource.Group != "" { + suiteTestPath = filepath.Join("internal", "controller", p.resource.Group, "suite_test.go") + } else { + suiteTestPath = filepath.Join("internal", "controller", "suite_test.go") + } + + // Check if file exists (using util.HasFileContentWith to check existence) + exists, err := util.HasFileContentWith(suiteTestPath, "package") + if err != nil || !exists { + return + } + + repo := p.config.GetRepository() + importAlias := strings.ToLower(p.resource.Group) + p.resource.Version + + // Check if any other resources in the same group share the same version + hasOtherResourcesSameVersion := false + resources, err := p.config.GetResources() + if err == nil { + for _, res := range resources { + // Skip the resource we're deleting + if res.Group == p.resource.Group && res.Version == p.resource.Version && res.Kind == p.resource.Kind { + continue + } + // Check if another resource in the same test suite shares the same group+version + // In multigroup mode, resources in different groups have different suite_test.go files + if res.Group == p.resource.Group && res.Version == p.resource.Version && res.HasController() { + hasOtherResourcesSameVersion = true + break + } + } + } + + if hasOtherResourcesSameVersion { + log.Info("Other controllers share the same version in suite_test.go, keeping shared import", + "version", p.resource.Version, "alias", importAlias) + } else { + // Remove import + var importPath string + if p.config.IsMultiGroup() && p.resource.Group != "" { + importPath = fmt.Sprintf("%s/api/%s/%s", repo, p.resource.Group, p.resource.Version) + } else { + importPath = fmt.Sprintf("%s/api/%s", repo, p.resource.Version) + } + + importToRemove := fmt.Sprintf("\t"+suiteTestImportCodeFragment, importAlias, importPath) + if err := util.ReplaceInFile(suiteTestPath, importToRemove, ""); err != nil { + log.Warn("Unable to remove import from suite_test.go - manual cleanup needed", "error", err) + p.manualCleanupSuiteTestImport = true + } else { + log.Info("Removed import from suite_test.go") + } + + // Remove AddToScheme + schemeToRemove := fmt.Sprintf("\t"+suiteTestAddSchemeCodeFragment, importAlias) + if err := util.ReplaceInFile(suiteTestPath, schemeToRemove, ""); err != nil { + log.Warn("Unable to remove AddToScheme from suite_test.go - manual cleanup needed", "error", err) + p.manualCleanupSuiteTestScheme = true + } else { + log.Info("Removed AddToScheme from suite_test.go") + } + } +} + +func (p *deleteAPISubcommand) confirmDeletion() bool { + fmt.Printf("\nWarning: You are about to delete the API for %s/%s (%s)\n", + p.resource.Group, p.resource.Version, p.resource.Kind) + fmt.Println("This will remove:") + fmt.Println(" - API type definitions") + if p.resource.HasController() { + fmt.Println(" - Controller and test files") + } + fmt.Println(" - Resource entry from PROJECT file") + fmt.Println(" - Sample files and CRD kustomization (best effort)") + fmt.Println("\nThis operation cannot be undone!") + fmt.Print("\nAre you sure you want to continue? [y/N]: ") + + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil { + log.Error("Failed to read user input", "error", err) + return false + } + + // Remove whitespace (handles both Unix \n and Windows \r\n) + response = strings.TrimSpace(response) + return response == "y" || response == "Y" || response == "yes" || response == "YES" +} + +// cleanupEmptyGroupDirectories removes empty group directories in multigroup mode +func (p *deleteAPISubcommand) cleanupEmptyGroupDirectories(fs machinery.Filesystem) { + // Check if this was the last resource in this group + isLastInGroup := true + resources, err := p.config.GetResources() + if err == nil { + for _, res := range resources { + // Skip the resource we're deleting + if res.Group == p.resource.Group && res.Version == p.resource.Version && res.Kind == p.resource.Kind { + continue + } + // Check if another resource exists in the same group + if res.Group == p.resource.Group { + isLastInGroup = false + break + } + } + } + + if !isLastInGroup { + return // Other resources in this group still exist + } + + // Try to remove group directories (they should be empty now) + apiGroupDir := filepath.Join("api", p.resource.Group) + controllerGroupDir := filepath.Join("internal", "controller", p.resource.Group) + + // Remove API group directory + if err := fs.FS.RemoveAll(apiGroupDir); err != nil { + log.Warn("Failed to remove empty API group directory", "dir", apiGroupDir, "error", err) + } else { + log.Info("Removed empty API group directory", "dir", apiGroupDir) + } + + // Remove controller group directory + if err := fs.FS.RemoveAll(controllerGroupDir); err != nil { + log.Warn("Failed to remove empty controller group directory", "dir", controllerGroupDir, "error", err) + } else { + log.Info("Removed empty controller group directory", "dir", controllerGroupDir) + } +} + +// cleanupSharedAPIFiles removes shared Go files created for this API version (best effort) +// Note: Kustomize files (samples, RBAC, kustomization.yaml) are cleaned by kustomize/v2 plugin +// Returns list of successfully deleted files +func (p *deleteAPISubcommand) cleanupSharedAPIFiles(fs machinery.Filesystem) []string { + deleted := []string{} + multigroup := p.config.IsMultiGroup() + + // Delete groupversion_info.go if this is the last API in this version + if p.isLastAPIInVersion() { + var groupVersionPath string + if multigroup && p.resource.Group != "" { + groupVersionPath = filepath.Join("api", p.resource.Group, p.resource.Version, "groupversion_info.go") + } else { + groupVersionPath = filepath.Join("api", p.resource.Version, "groupversion_info.go") + } + + if exists, _ := afero.Exists(fs.FS, groupVersionPath); exists { + if err := fs.FS.Remove(groupVersionPath); err != nil { + log.Warn("Failed to delete groupversion_info.go", "file", groupVersionPath, "error", err) + } else { + log.Info("Deleted groupversion_info.go", "file", groupVersionPath) + deleted = append(deleted, groupVersionPath) + } + } + } + + return deleted +} + +// isLastAPIInVersion checks if this is the last API in this specific version +func (p *deleteAPISubcommand) isLastAPIInVersion() bool { + resources, err := p.config.GetResources() + if err != nil { + return false + } + + for _, res := range resources { + // Skip the current resource being deleted + if res.Group == p.resource.Group && res.Version == p.resource.Version && res.Kind == p.resource.Kind { + continue + } + // Check if any other resource exists in the same group/version + if res.Group == p.resource.Group && res.Version == p.resource.Version { + return false + } + } + + return true +} + +// isLastControllerInGroup checks if this is the last controller in this group +// Used to determine if controller suite_test.go should be deleted +func (p *deleteAPISubcommand) isLastControllerInGroup() bool { + if !p.resource.HasController() { + return false + } + + resources, err := p.config.GetResources() + if err != nil { + return false + } + + for _, res := range resources { + // Skip the current resource being deleted + if res.Group == p.resource.Group && res.Version == p.resource.Version && res.Kind == p.resource.Kind { + continue + } + // For multigroup, check same group; for single group, check any controller + if p.config.IsMultiGroup() { + if res.Group == p.resource.Group && res.Controller { + return false + } + } else { + if res.Controller { + return false + } + } + } + + return true +} diff --git a/pkg/plugins/golang/v4/delete_api_test.go b/pkg/plugins/golang/v4/delete_api_test.go new file mode 100644 index 00000000000..0697524e887 --- /dev/null +++ b/pkg/plugins/golang/v4/delete_api_test.go @@ -0,0 +1,100 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v4 + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "sigs.k8s.io/kubebuilder/v4/pkg/config" + cfgv3 "sigs.k8s.io/kubebuilder/v4/pkg/config/v3" + "sigs.k8s.io/kubebuilder/v4/pkg/model/resource" +) + +var _ = Describe("DeleteAPI", func() { + Context("InjectResource", func() { + var ( + subcmd deleteAPISubcommand + cfg config.Config + ) + + BeforeEach(func() { + cfg = cfgv3.New() + subcmd = deleteAPISubcommand{ + config: cfg, + } + }) + + It("should fail if resource does not exist in config", func() { + res := &resource.Resource{ + GVK: resource.GVK{ + Group: "crew", + Version: "v1", + Kind: "Captain", + }, + } + + err := subcmd.InjectResource(res) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not exist")) + }) + + It("should fail if resource has webhooks", func() { + res := resource.Resource{ + GVK: resource.GVK{ + Group: "crew", + Version: "v1", + Kind: "Captain", + }, + API: &resource.API{ + CRDVersion: "v1", + }, + Webhooks: &resource.Webhooks{ + WebhookVersion: "v1", + Defaulting: true, + }, + } + + Expect(cfg.AddResource(res)).To(Succeed()) + + err := subcmd.InjectResource(&res) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("webhooks are configured")) + Expect(err.Error()).To(ContainSubstring("delete the webhooks first")) + }) + + It("should succeed if resource exists without webhooks", func() { + res := resource.Resource{ + GVK: resource.GVK{ + Group: "crew", + Version: "v1", + Kind: "Captain", + }, + API: &resource.API{ + CRDVersion: "v1", + }, + } + + Expect(cfg.AddResource(res)).To(Succeed()) + + err := subcmd.InjectResource(&res) + Expect(err).NotTo(HaveOccurred()) + Expect(subcmd.resource).NotTo(BeNil()) + Expect(subcmd.resource.Kind).To(Equal("Captain")) + }) + }) +}) diff --git a/pkg/plugins/golang/v4/delete_integration_test.go b/pkg/plugins/golang/v4/delete_integration_test.go new file mode 100644 index 00000000000..15dad2f81a0 --- /dev/null +++ b/pkg/plugins/golang/v4/delete_integration_test.go @@ -0,0 +1,1033 @@ +// Copyright 2026 The Kubernetes Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build integration + +package v4 + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "sigs.k8s.io/kubebuilder/v4/pkg/plugin/util" + "sigs.k8s.io/kubebuilder/v4/test/e2e/utils" +) + +var _ = Describe("Delete Comprehensive Integration Tests", func() { + var kbc *utils.TestContext + + BeforeEach(func() { + var err error + kbc, err = utils.NewTestContext(util.KubebuilderBinName, "GO111MODULE=on") + Expect(err).NotTo(HaveOccurred()) + Expect(kbc.Prepare()).To(Succeed()) + + err = kbc.Init("--plugins", "go/v4", "--domain", kbc.Domain, "--repo", kbc.Domain, "--skip-go-version-check") + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + kbc.Destroy() + }) + + It("should comprehensively test all delete scenarios with state verification", func() { + By("Scenario 1: Error conditions") + err := kbc.DeleteAPI("--group", "none", "--version", "v1", "--kind", "Missing", "-y") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not exist")) + + By("Scenario 2: API deletion protection when webhooks exist") + err = kbc.CreateAPI("--group", "test", "--version", "v1", "--kind", "Protected", + "--resource", "--controller", "--make=false") + Expect(err).NotTo(HaveOccurred()) + + err = kbc.CreateWebhook("--group", "test", "--version", "v1", "--kind", "Protected", + "--defaulting", "--programmatic-validation", "--make=false") + Expect(err).NotTo(HaveOccurred()) + + err = kbc.DeleteAPI("--group", "test", "--version", "v1", "--kind", "Protected", "-y") + Expect(err).To(HaveOccurred(), "API deletion should be blocked") + Expect(err.Error()).To(ContainSubstring("webhooks are configured")) + + By("Scenario 3: Granular webhook deletion and PROJECT updates") + projectPath := filepath.Join(kbc.Dir, "PROJECT") + + err = kbc.DeleteWebhook("--group", "test", "--version", "v1", "--kind", "Protected", + "--defaulting", "-y") + Expect(err).NotTo(HaveOccurred()) + + projectContent, _ := os.ReadFile(projectPath) + Expect(string(projectContent)).NotTo(ContainSubstring("defaulting: true")) + Expect(string(projectContent)).To(ContainSubstring("validation: true")) + Expect(kbc.HasFile("internal/webhook/v1/protected_webhook.go")).To(BeTrue(), + "files remain when validation exists") + + err = kbc.DeleteWebhook("--group", "test", "--version", "v1", "--kind", "Protected", + "--programmatic-validation", "-y") + Expect(err).NotTo(HaveOccurred()) + + Expect(kbc.HasFile("internal/webhook/v1/protected_webhook.go")).To(BeFalse(), + "files deleted when all types removed") + Expect(kbc.HasFile("config/certmanager/kustomization.yaml")).To(BeFalse(), + "certmanager deleted with last webhook") + + err = kbc.DeleteAPI("--group", "test", "--version", "v1", "--kind", "Protected", "-y") + Expect(err).NotTo(HaveOccurred()) + + By("Scenario 4: Error when deleting non-existent webhook type") + err = kbc.CreateAPI("--group", "test", "--version", "v1", "--kind", "NoWebhook", + "--resource", "--controller", "--make=false") + Expect(err).NotTo(HaveOccurred()) + + err = kbc.DeleteWebhook("--group", "test", "--version", "v1", "--kind", "NoWebhook", "-y") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not have any webhooks")) + + err = kbc.CreateWebhook("--group", "test", "--version", "v1", "--kind", "NoWebhook", + "--defaulting", "--make=false") + Expect(err).NotTo(HaveOccurred()) + + err = kbc.DeleteWebhook("--group", "test", "--version", "v1", "--kind", "NoWebhook", + "--programmatic-validation", "-y") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not have a validation webhook")) + }) + + It("should verify all API deletion scenarios with exact state restoration", func() { + By("Scenario 1: Simple API - before equals after delete") + By("capturing initial state before creating API") + mainPathBefore := filepath.Join(kbc.Dir, "cmd", "main.go") + + mainContentBefore, err := os.ReadFile(mainPathBefore) + Expect(err).NotTo(HaveOccurred()) + + By("verifying baseline is clean (no pre-existing scaffolded code)") + Expect(string(mainContentBefore)).NotTo(ContainSubstring("crewv1 \""), + "baseline main.go should not contain crew/v1 imports") + Expect(string(mainContentBefore)).NotTo(ContainSubstring("SailorReconciler"), + "baseline main.go should not contain SailorReconciler") + + By("creating an API with controller") + err = kbc.CreateAPI( + "--group", "crew", + "--version", "v1", + "--kind", "Sailor", + "--resource", + "--controller", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred()) + + By("verifying code was added to main.go") + mainContentAfterCreate, err := os.ReadFile(mainPathBefore) + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainContentAfterCreate)).To(ContainSubstring("crewv1 \""), + "import should be added") + Expect(string(mainContentAfterCreate)).To(ContainSubstring("utilruntime.Must(crewv1.AddToScheme(scheme))"), + "AddToScheme should be added") + Expect(string(mainContentAfterCreate)).To(ContainSubstring("&controller.SailorReconciler{"), + "controller setup should be added") + + By("verifying PROJECT file was updated") + projectPath := filepath.Join(kbc.Dir, "PROJECT") + projectContentAfterCreate, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectContentAfterCreate)).To(ContainSubstring("kind: Sailor"), + "resource should be in PROJECT") + + By("verifying files were created") + Expect(kbc.HasFile("api/v1/sailor_types.go")).To(BeTrue()) + Expect(kbc.HasFile("internal/controller/sailor_controller.go")).To(BeTrue()) + Expect(kbc.HasFile("internal/controller/suite_test.go")).To(BeTrue()) + Expect(kbc.HasFile("config/samples/crew_v1_sailor.yaml")).To(BeTrue()) + Expect(kbc.HasFile("config/rbac/sailor_admin_role.yaml")).To(BeTrue()) + + By("deleting the API") + err = kbc.DeleteAPI( + "--group", "crew", + "--version", "v1", + "--kind", "Sailor", + "-y", + ) + Expect(err).NotTo(HaveOccurred()) + + By("verifying code was REMOVED from main.go") + mainContentAfterDelete, err := os.ReadFile(mainPathBefore) + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainContentAfterDelete)).NotTo(ContainSubstring("crewv1 \""), + "import should be removed") + Expect(string(mainContentAfterDelete)).NotTo(ContainSubstring("utilruntime.Must(crewv1.AddToScheme(scheme))"), + "AddToScheme should be removed") + Expect(string(mainContentAfterDelete)).NotTo(ContainSubstring("&controller.SailorReconciler{"), + "controller setup should be removed") + + By("verifying PROJECT file was updated") + projectContentAfterDelete, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectContentAfterDelete)).NotTo(ContainSubstring("kind: Sailor"), + "resource should be removed from PROJECT") + + By("verifying files were deleted") + Expect(kbc.HasFile("api/v1/sailor_types.go")).To(BeFalse()) + Expect(kbc.HasFile("internal/controller/sailor_controller.go")).To(BeFalse()) + Expect(kbc.HasFile("config/samples/crew_v1_sailor.yaml")).To(BeFalse()) + Expect(kbc.HasFile("config/rbac/sailor_admin_role.yaml")).To(BeFalse()) + + By("verifying main.go matches initial state") + Expect(string(mainContentAfterDelete)).To(Equal(string(mainContentBefore)), + "main.go after delete should exactly match before create") + + By("verifying PROJECT file matches initial state (excluding layout version)") + // PROJECT may have minor formatting differences, but resource list should match + Expect(string(projectContentAfterDelete)).NotTo(ContainSubstring("resources:"), + "PROJECT should have no resources after deleting last API") + }) + + It("should completely undo create webhook operation - before equals after delete", func() { + By("creating API first (required for webhook)") + err := kbc.CreateAPI( + "--group", "crew", + "--version", "v1", + "--kind", "Pilot", + "--resource", + "--controller=false", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred()) + + By("capturing state before creating webhook") + mainPath := filepath.Join(kbc.Dir, "cmd", "main.go") + mainContentBefore, err := os.ReadFile(mainPath) + Expect(err).NotTo(HaveOccurred()) + + By("creating webhook") + err = kbc.CreateWebhook( + "--group", "crew", + "--version", "v1", + "--kind", "Pilot", + "--defaulting", + "--programmatic-validation", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred()) + + By("verifying webhook code was added to main.go") + mainContentAfterCreate, err := os.ReadFile(mainPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainContentAfterCreate)).To(ContainSubstring("webhookv1 \""), + "webhook import should be added") + Expect(string(mainContentAfterCreate)).To(ContainSubstring("webhookv1.SetupPilotWebhookWithManager"), + "webhook setup should be added") + + By("verifying webhook files were created") + Expect(kbc.HasFile("internal/webhook/v1/pilot_webhook.go")).To(BeTrue()) + Expect(kbc.HasFile("config/webhook/service.yaml")).To(BeTrue()) + Expect(kbc.HasFile("config/certmanager/kustomization.yaml")).To(BeTrue()) + + By("deleting the webhook") + err = kbc.DeleteWebhook( + "--group", "crew", + "--version", "v1", + "--kind", "Pilot", + "-y", + ) + Expect(err).NotTo(HaveOccurred()) + + By("verifying webhook code was REMOVED from main.go") + mainContentAfterDelete, err := os.ReadFile(mainPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainContentAfterDelete)).NotTo(ContainSubstring("webhookv1 \""), + "webhook import should be removed") + Expect(string(mainContentAfterDelete)).NotTo(ContainSubstring("webhookv1.SetupPilotWebhookWithManager"), + "webhook setup should be removed") + + By("verifying main.go matches state before webhook was created") + Expect(string(mainContentAfterDelete)).To(Equal(string(mainContentBefore)), + "main.go after delete webhook should exactly match before create webhook") + + By("verifying webhook files were deleted") + Expect(kbc.HasFile("internal/webhook/v1/pilot_webhook.go")).To(BeFalse()) + Expect(kbc.HasFile("config/webhook/service.yaml")).To(BeFalse()) + Expect(kbc.HasFile("config/certmanager/kustomization.yaml")).To(BeFalse()) + + By("verifying can recreate the same webhook (project is clean)") + err = kbc.CreateWebhook( + "--group", "crew", + "--version", "v1", + "--kind", "Pilot", + "--defaulting", + "--programmatic-validation", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred(), "should be able to recreate the same webhook") + Expect(kbc.HasFile("internal/webhook/v1/pilot_webhook.go")).To(BeTrue()) + }) + + It("should handle complete create-delete cycle with multiple APIs", func() { + By("capturing initial state") + mainPath := filepath.Join(kbc.Dir, "cmd", "main.go") + crdKustomizePath := filepath.Join(kbc.Dir, "config", "crd", "kustomization.yaml") + samplesKustomizePath := filepath.Join(kbc.Dir, "config", "samples", "kustomization.yaml") + rbacKustomizePath := filepath.Join(kbc.Dir, "config", "rbac", "kustomization.yaml") + + mainContentBefore, err := os.ReadFile(mainPath) + Expect(err).NotTo(HaveOccurred()) + + By("creating three APIs") + err = kbc.CreateAPI("--group", "crew", "--version", "v1", "--kind", "First", + "--resource", "--controller", "--make=false") + Expect(err).NotTo(HaveOccurred()) + + err = kbc.CreateAPI("--group", "crew", "--version", "v1", "--kind", "Second", + "--resource", "--controller", "--make=false") + Expect(err).NotTo(HaveOccurred()) + + err = kbc.CreateAPI("--group", "crew", "--version", "v1", "--kind", "Third", + "--resource", "--controller", "--make=false") + Expect(err).NotTo(HaveOccurred()) + + By("verifying all three APIs in kustomization files") + crdContent, _ := os.ReadFile(crdKustomizePath) + Expect(string(crdContent)).To(ContainSubstring("crew." + kbc.Domain + "_firsts.yaml")) + Expect(string(crdContent)).To(ContainSubstring("crew." + kbc.Domain + "_seconds.yaml")) + Expect(string(crdContent)).To(ContainSubstring("crew." + kbc.Domain + "_thirds.yaml")) + + By("deleting second API (middle)") + err = kbc.DeleteAPI("--group", "crew", "--version", "v1", "--kind", "Second", + "-y") + Expect(err).NotTo(HaveOccurred()) + + By("verifying only Second was removed from kustomization") + crdContent, _ = os.ReadFile(crdKustomizePath) + Expect(string(crdContent)).To(ContainSubstring("crew." + kbc.Domain + "_firsts.yaml")) + Expect(string(crdContent)).NotTo(ContainSubstring("crew." + kbc.Domain + "_seconds.yaml")) + Expect(string(crdContent)).To(ContainSubstring("crew." + kbc.Domain + "_thirds.yaml")) + + samplesContent, _ := os.ReadFile(samplesKustomizePath) + Expect(string(samplesContent)).To(ContainSubstring("crew_v1_first.yaml")) + Expect(string(samplesContent)).NotTo(ContainSubstring("crew_v1_second.yaml")) + Expect(string(samplesContent)).To(ContainSubstring("crew_v1_third.yaml")) + + rbacContent, _ := os.ReadFile(rbacKustomizePath) + Expect(string(rbacContent)).To(ContainSubstring("first_admin_role.yaml")) + Expect(string(rbacContent)).NotTo(ContainSubstring("second_admin_role.yaml")) + Expect(string(rbacContent)).To(ContainSubstring("third_admin_role.yaml")) + + By("verifying Second's code removed from main.go but First and Third remain") + mainContent, _ := os.ReadFile(mainPath) + // All three APIs share same version, so import remains (shared) + Expect(string(mainContent)).To(ContainSubstring("crewv1 \"")) + // But controller setups should be specific + Expect(string(mainContent)).To(ContainSubstring("FirstReconciler")) + Expect(string(mainContent)).NotTo(ContainSubstring("SecondReconciler")) + Expect(string(mainContent)).To(ContainSubstring("ThirdReconciler")) + + By("deleting remaining APIs") + err = kbc.DeleteAPI("--group", "crew", "--version", "v1", "--kind", "First", + "-y") + Expect(err).NotTo(HaveOccurred()) + + err = kbc.DeleteAPI("--group", "crew", "--version", "v1", "--kind", "Third", + "-y") + Expect(err).NotTo(HaveOccurred()) + + By("verifying all API code removed and main.go restored") + mainContentFinal, _ := os.ReadFile(mainPath) + Expect(string(mainContentFinal)).NotTo(ContainSubstring("crewv1 \"")) + Expect(string(mainContentFinal)).NotTo(ContainSubstring("FirstReconciler")) + Expect(string(mainContentFinal)).NotTo(ContainSubstring("ThirdReconciler")) + Expect(string(mainContentFinal)).To(Equal(string(mainContentBefore)), + "main.go should match initial state after all APIs deleted") + + By("verifying kustomization files were deleted with last API") + Expect(kbc.HasFile("config/crd/kustomization.yaml")).To(BeFalse()) + Expect(kbc.HasFile("config/samples/kustomization.yaml")).To(BeFalse()) + + By("Scenario 3: Multigroup layout - delete undoes create") + By("enabling multigroup") + err = kbc.Edit("--multigroup") + Expect(err).NotTo(HaveOccurred()) + + By("capturing state before create") + mainPathMulti := filepath.Join(kbc.Dir, "cmd", "main.go") + mainContentBeforeMulti, err := os.ReadFile(mainPathMulti) + Expect(err).NotTo(HaveOccurred()) + + By("creating multigroup API") + err = kbc.CreateAPI( + "--group", "ship", + "--version", "v1", + "--kind", "Cruiser", + "--resource", + "--controller", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred()) + + By("verifying multigroup paths") + Expect(kbc.HasFile("api/ship/v1/cruiser_types.go")).To(BeTrue()) + Expect(kbc.HasFile("internal/controller/ship/cruiser_controller.go")).To(BeTrue()) + + By("verifying multigroup imports in main.go") + mainContent, err = os.ReadFile(mainPathMulti) + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainContent)).To(ContainSubstring("shipv1 \"")) + Expect(string(mainContent)).To(ContainSubstring("shipcontroller \"")) + Expect(string(mainContent)).To(ContainSubstring("shipcontroller.CruiserReconciler")) + + By("deleting multigroup API") + err = kbc.DeleteAPI( + "--group", "ship", + "--version", "v1", + "--kind", "Cruiser", + "-y", + ) + Expect(err).NotTo(HaveOccurred()) + + By("verifying main.go restored to before state") + mainContentAfter, err := os.ReadFile(mainPathMulti) + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainContentAfter)).NotTo(ContainSubstring("shipv1 \"")) + Expect(string(mainContentAfter)).NotTo(ContainSubstring("shipcontroller \"")) + Expect(string(mainContentAfter)).To(Equal(string(mainContentBeforeMulti)), + "multigroup main.go should match initial state") + + By("verifying files deleted") + Expect(kbc.HasFile("api/ship/v1/cruiser_types.go")).To(BeFalse()) + Expect(kbc.HasFile("internal/controller/ship/cruiser_controller.go")).To(BeFalse()) + }) + + It("should verify all webhook deletion scenarios with exact state restoration", func() { + By("Scenario 1: Simple webhook - before equals after delete") + By("creating API for conversion webhook") + err := kbc.CreateAPI( + "--group", "test", + "--version", "v1", + "--kind", "Converter", + "--resource", + "--controller=false", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred()) + + By("capturing CRD kustomization before conversion webhook") + crdKustomizePath := filepath.Join(kbc.Dir, "config", "crd", "kustomization.yaml") + crdContentBefore, err := os.ReadFile(crdKustomizePath) + Expect(err).NotTo(HaveOccurred()) + + By("creating conversion webhook") + err = kbc.CreateWebhook( + "--group", "test", + "--version", "v1", + "--kind", "Converter", + "--conversion", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred()) + + By("verifying conversion webhook patch was added") + crdContentAfterCreate, err := os.ReadFile(crdKustomizePath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(crdContentAfterCreate)).To(ContainSubstring("patches/webhook_in_converters.yaml"), + "conversion webhook patch should be added to kustomization") + Expect(kbc.HasFile("config/crd/patches/webhook_in_converters.yaml")).To(BeTrue(), + "conversion webhook patch file should exist") + + By("deleting conversion webhook") + err = kbc.DeleteWebhook( + "--group", "test", + "--version", "v1", + "--kind", "Converter", + "-y", + ) + Expect(err).NotTo(HaveOccurred()) + + By("verifying conversion webhook patch was removed") + crdContentAfterDelete, err := os.ReadFile(crdKustomizePath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(crdContentAfterDelete)).NotTo(ContainSubstring("patches/webhook_in_converters.yaml"), + "conversion webhook patch should be removed from kustomization") + Expect(kbc.HasFile("config/crd/patches/webhook_in_converters.yaml")).To(BeFalse(), + "conversion webhook patch file should be deleted") + + By("verifying CRD kustomization matches state before conversion webhook") + Expect(string(crdContentAfterDelete)).To(Equal(string(crdContentBefore)), + "CRD kustomization after delete should match before create webhook") + + By("Scenario 2: Conversion removal with other webhooks remaining") + By("creating API") + err = kbc.CreateAPI( + "--group", "test", + "--version", "v2", + "--kind", "Ship", + "--resource", + "--controller=false", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred()) + + By("creating defaulting and validation webhooks first") + err = kbc.CreateWebhook( + "--group", "test", + "--version", "v2", + "--kind", "Ship", + "--defaulting", + "--programmatic-validation", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred()) + + By("saving state with defaulting and validation webhooks (baseline)") + crdPath := filepath.Join(kbc.Dir, "config", "crd", "kustomization.yaml") + mainPath := filepath.Join(kbc.Dir, "cmd", "main.go") + projectPath := filepath.Join(kbc.Dir, "PROJECT") + + crdBaseline, err := os.ReadFile(crdPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(crdBaseline)).NotTo(ContainSubstring("webhook_in_ships.yaml"), + "no conversion patch at baseline") + + mainBaseline, err := os.ReadFile(mainPath) + Expect(err).NotTo(HaveOccurred()) + + projectBaseline, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectBaseline)).To(ContainSubstring("defaulting: true")) + Expect(string(projectBaseline)).To(ContainSubstring("validation: true")) + Expect(string(projectBaseline)).NotTo(ContainSubstring("conversion: true")) + + By("adding conversion webhook") + err = kbc.CreateWebhook( + "--group", "test", + "--version", "v2", + "--kind", "Ship", + "--conversion", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred()) + + By("verifying conversion patch was added") + crdWithConversion, err := os.ReadFile(crdPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(crdWithConversion)).To(ContainSubstring("webhook_in_ships.yaml")) + Expect(kbc.HasFile("config/crd/patches/webhook_in_ships.yaml")).To(BeTrue()) + + projectWithConversion, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectWithConversion)).To(ContainSubstring("conversion: true")) + + By("deleting ONLY conversion webhook (defaulting and validation remain)") + err = kbc.DeleteWebhook( + "--group", "test", + "--version", "v2", + "--kind", "Ship", + "--conversion", + "-y", + ) + Expect(err).NotTo(HaveOccurred()) + + By("verifying conversion patch removed from kustomization") + crdAfterDelete, err := os.ReadFile(crdPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(crdAfterDelete)).NotTo(ContainSubstring("webhook_in_ships.yaml"), + "conversion patch must be removed") + Expect(kbc.HasFile("config/crd/patches/webhook_in_ships.yaml")).To(BeFalse(), + "patch file must be deleted") + + By("verifying PROJECT reflects only defaulting and validation") + projectAfterDelete, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectAfterDelete)).To(ContainSubstring("defaulting: true")) + Expect(string(projectAfterDelete)).To(ContainSubstring("validation: true")) + Expect(string(projectAfterDelete)).NotTo(ContainSubstring("conversion: true")) + + By("verifying CRD kustomization matches baseline (with defaulting/validation)") + Expect(string(crdAfterDelete)).To(Equal(string(crdBaseline)), + "CRD kustomization must match baseline state with defaulting/validation") + + By("verifying main.go unchanged (webhook import/setup remain for defaulting/validation)") + mainAfterDelete, err := os.ReadFile(mainPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainAfterDelete)).To(Equal(string(mainBaseline)), + "main.go must match baseline (webhook code remains for other types)") + + By("verifying can recreate conversion webhook (project is clean)") + err = kbc.CreateWebhook( + "--group", "test", + "--version", "v2", + "--kind", "Ship", + "--conversion", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred(), "should be able to recreate conversion webhook") + Expect(kbc.HasFile("config/crd/patches/webhook_in_ships.yaml")).To(BeTrue()) + }) + + It("should verify deploy-image plugin chain - baseline restored after deletion", func() { + By("creating first API (regular, establishes baseline)") + err := kbc.CreateAPI( + "--group", "app", + "--version", "v1", + "--kind", "Database", + "--resource", + "--controller", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred()) + + By("capturing baseline state (with one regular API)") + mainPath := filepath.Join(kbc.Dir, "cmd", "main.go") + projectPath := filepath.Join(kbc.Dir, "PROJECT") + crdKustomizePath := filepath.Join(kbc.Dir, "config", "crd", "kustomization.yaml") + + mainBaseline, err := os.ReadFile(mainPath) + Expect(err).NotTo(HaveOccurred()) + + projectBaseline, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectBaseline)).To(ContainSubstring("kind: Database")) + Expect(string(projectBaseline)).NotTo(ContainSubstring("deploy-image")) + + By("creating second API with deploy-image plugin") + err = kbc.CreateAPI( + "--group", "app", + "--version", "v1alpha1", + "--kind", "Cache", + "--image", "redis:7-alpine", + "--plugins", "deploy-image/v1-alpha", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred()) + + By("verifying deploy-image API was created") + Expect(kbc.HasFile("api/v1alpha1/cache_types.go")).To(BeTrue()) + Expect(kbc.HasFile("internal/controller/cache_controller.go")).To(BeTrue()) + + projectWithCache, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectWithCache)).To(ContainSubstring("kind: Cache")) + Expect(string(projectWithCache)).To(ContainSubstring("deploy-image.go.kubebuilder.io/v1-alpha")) + + mainWithCache, err := os.ReadFile(mainPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainWithCache)).To(ContainSubstring("appv1alpha1")) + Expect(string(mainWithCache)).To(ContainSubstring("CacheReconciler")) + + By("deleting deploy-image API with plugin chain") + err = kbc.DeleteAPI( + "--group", "app", + "--version", "v1alpha1", + "--kind", "Cache", + "--plugins", "deploy-image/v1-alpha", + "-y", + ) + Expect(err).NotTo(HaveOccurred()) + + By("verifying deploy-image API files deleted") + Expect(kbc.HasFile("api/v1alpha1/cache_types.go")).To(BeFalse()) + Expect(kbc.HasFile("internal/controller/cache_controller.go")).To(BeFalse()) + + By("verifying main.go restored to baseline") + mainAfterDelete, err := os.ReadFile(mainPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainAfterDelete)).To(Equal(string(mainBaseline)), + "main.go must match baseline (only Database API remains)") + + By("verifying PROJECT restored to baseline") + projectAfterDeleteCache, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectAfterDeleteCache)).To(ContainSubstring("kind: Database")) + Expect(string(projectAfterDeleteCache)).NotTo(ContainSubstring("kind: Cache")) + Expect(string(projectAfterDeleteCache)).NotTo(ContainSubstring("deploy-image.go.kubebuilder.io/v1-alpha")) + + By("verifying CRD kustomization contains only Database") + crdAfterDelete, err := os.ReadFile(crdKustomizePath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(crdAfterDelete)).To(ContainSubstring("databases.yaml")) + Expect(string(crdAfterDelete)).NotTo(ContainSubstring("caches.yaml")) + + By("verifying can recreate the same API with deploy-image (project is clean)") + err = kbc.CreateAPI( + "--group", "app", + "--version", "v1alpha1", + "--kind", "Cache", + "--image", "redis:7-alpine", + "--plugins", "deploy-image/v1-alpha", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred(), "should be able to recreate deploy-image API") + Expect(kbc.HasFile("api/v1alpha1/cache_types.go")).To(BeTrue()) + }) + + It("should verify optional plugins deletion - baseline restored after adding and removing", func() { + By("creating base API for manifests") + err := kbc.CreateAPI( + "--group", "app", + "--version", "v1", + "--kind", "Service", + "--resource", + "--controller", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred()) + + By("capturing baseline state (before optional plugins)") + projectPath := filepath.Join(kbc.Dir, "PROJECT") + + projectBaseline, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectBaseline)).NotTo(ContainSubstring("helm.kubebuilder.io")) + Expect(string(projectBaseline)).NotTo(ContainSubstring("grafana.kubebuilder.io")) + + By("adding grafana plugin") + err = kbc.Edit("--plugins=grafana/v1-alpha") + Expect(err).NotTo(HaveOccurred()) + + By("verifying grafana files created") + Expect(kbc.HasFile("grafana/controller-runtime-metrics.json")).To(BeTrue()) + + projectWithGrafana, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectWithGrafana)).To(ContainSubstring("grafana.kubebuilder.io/v1-alpha")) + + By("adding helm plugin (need install.yaml first)") + err = kbc.Make("build-installer") + Expect(err).NotTo(HaveOccurred()) + + err = kbc.Edit("--plugins=helm/v2-alpha") + Expect(err).NotTo(HaveOccurred()) + + By("verifying helm files created") + Expect(kbc.HasFile("dist/chart/Chart.yaml")).To(BeTrue()) + Expect(kbc.HasFile("dist/chart/values.yaml")).To(BeTrue()) + + projectWithBoth, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectWithBoth)).To(ContainSubstring("grafana.kubebuilder.io/v1-alpha")) + Expect(string(projectWithBoth)).To(ContainSubstring("helm.kubebuilder.io/v2-alpha")) + + By("removing grafana plugin") + err = kbc.Delete("--plugins=grafana/v1-alpha") + Expect(err).NotTo(HaveOccurred()) + + By("verifying grafana files deleted") + Expect(kbc.HasFile("grafana/controller-runtime-metrics.json")).To(BeFalse()) + Expect(kbc.HasFile("grafana")).To(BeFalse()) + + projectWithoutGrafana, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectWithoutGrafana)).NotTo(ContainSubstring("grafana.kubebuilder.io/v1-alpha")) + Expect(string(projectWithoutGrafana)).To(ContainSubstring("helm.kubebuilder.io/v2-alpha"), + "helm should still be present") + + By("removing helm plugin") + err = kbc.Delete("--plugins=helm/v2-alpha") + Expect(err).NotTo(HaveOccurred()) + + By("verifying helm files deleted") + Expect(kbc.HasFile("dist/chart/Chart.yaml")).To(BeFalse()) + Expect(kbc.HasFile("dist/chart")).To(BeFalse()) + Expect(kbc.HasFile(".github/workflows/test-chart.yml")).To(BeFalse()) + + By("verifying PROJECT restored to baseline") + projectFinal, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectFinal)).NotTo(ContainSubstring("grafana.kubebuilder.io")) + Expect(string(projectFinal)).NotTo(ContainSubstring("helm.kubebuilder.io")) + Expect(string(projectFinal)).To(ContainSubstring("kind: Service"), + "base API should remain") + + By("verifying can re-add plugins (project is clean)") + err = kbc.Edit("--plugins=grafana/v1-alpha") + Expect(err).NotTo(HaveOccurred(), "should be able to re-add grafana") + Expect(kbc.HasFile("grafana/controller-runtime-metrics.json")).To(BeTrue()) + }) + + It("should handle API without controller and controller without API scenarios", func() { + By("Scenario 1: Delete resource-only API (no controller)") + mainPath := filepath.Join(kbc.Dir, "cmd", "main.go") + mainBefore, err := os.ReadFile(mainPath) + Expect(err).NotTo(HaveOccurred()) + + err = kbc.CreateAPI( + "--group", "data", + "--version", "v1", + "--kind", "Schema", + "--resource", + "--controller=false", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred()) + + By("verifying API files created but no controller") + Expect(kbc.HasFile("api/v1/schema_types.go")).To(BeTrue()) + Expect(kbc.HasFile("internal/controller/schema_controller.go")).To(BeFalse()) + + mainAfterCreate, err := os.ReadFile(mainPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainAfterCreate)).To(ContainSubstring("datav1")) + Expect(string(mainAfterCreate)).NotTo(ContainSubstring("SchemaReconciler")) + + By("deleting resource-only API") + err = kbc.DeleteAPI("--group", "data", "--version", "v1", "--kind", "Schema", "-y") + Expect(err).NotTo(HaveOccurred()) + + By("verifying complete restoration") + mainAfterDelete, err := os.ReadFile(mainPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainAfterDelete)).To(Equal(string(mainBefore)), + "main.go should match initial state") + Expect(kbc.HasFile("api/v1/schema_types.go")).To(BeFalse()) + + By("Scenario 2: Delete controller-only API (external resource)") + err = kbc.CreateAPI( + "--group", "cert-manager.io", + "--version", "v1", + "--kind", "Certificate", + "--resource=false", + "--controller", + "--external-api-domain=cert-manager.io", + "--external-api-path=github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred()) + + By("verifying controller created without API types") + Expect(kbc.HasFile("internal/controller/certificate_controller.go")).To(BeTrue()) + Expect(kbc.HasFile("api/v1/certificate_types.go")).To(BeFalse()) + + mainWithExternal, err := os.ReadFile(mainPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainWithExternal)).To(ContainSubstring("CertificateReconciler")) + + By("deleting external controller") + err = kbc.DeleteAPI("--group", "cert-manager.io", "--version", "v1", "--kind", "Certificate", "-y") + Expect(err).NotTo(HaveOccurred()) + + By("verifying external controller removed") + Expect(kbc.HasFile("internal/controller/certificate_controller.go")).To(BeFalse()) + mainFinal, err := os.ReadFile(mainPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainFinal)).NotTo(ContainSubstring("CertificateReconciler")) + }) + + It("should handle multigroup scenarios correctly", func() { + By("enabling multigroup") + err := kbc.Edit("--multigroup") + Expect(err).NotTo(HaveOccurred()) + + mainPath := filepath.Join(kbc.Dir, "cmd", "main.go") + projectPath := filepath.Join(kbc.Dir, "PROJECT") + + By("creating APIs in different groups") + err = kbc.CreateAPI( + "--group", "crew", + "--version", "v1", + "--kind", "Captain", + "--resource", + "--controller", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred()) + + err = kbc.CreateAPI( + "--group", "ship", + "--version", "v1", + "--kind", "Frigate", + "--resource", + "--controller", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred()) + + By("verifying multigroup structure") + Expect(kbc.HasFile("api/crew/v1/captain_types.go")).To(BeTrue()) + Expect(kbc.HasFile("api/ship/v1/frigate_types.go")).To(BeTrue()) + Expect(kbc.HasFile("internal/controller/crew/captain_controller.go")).To(BeTrue()) + Expect(kbc.HasFile("internal/controller/ship/frigate_controller.go")).To(BeTrue()) + + mainWithBoth, err := os.ReadFile(mainPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainWithBoth)).To(ContainSubstring("crewv1")) + Expect(string(mainWithBoth)).To(ContainSubstring("shipv1")) + Expect(string(mainWithBoth)).To(ContainSubstring("crewcontroller")) + Expect(string(mainWithBoth)).To(ContainSubstring("shipcontroller")) + + By("adding second API in crew group (shared import test)") + err = kbc.CreateAPI( + "--group", "crew", + "--version", "v1", + "--kind", "Sailor", + "--resource", + "--controller", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred()) + + By("deleting one crew API (import should remain for other)") + err = kbc.DeleteAPI("--group", "crew", "--version", "v1", "--kind", "Sailor", "-y") + Expect(err).NotTo(HaveOccurred()) + + mainAfterDelete, err := os.ReadFile(mainPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainAfterDelete)).To(ContainSubstring("crewv1"), + "shared import should remain for Captain") + Expect(string(mainAfterDelete)).To(ContainSubstring("crewcontroller"), + "shared controller import should remain") + Expect(string(mainAfterDelete)).To(ContainSubstring("CaptainReconciler")) + Expect(string(mainAfterDelete)).NotTo(ContainSubstring("SailorReconciler")) + + By("deleting remaining crew API") + err = kbc.DeleteAPI("--group", "crew", "--version", "v1", "--kind", "Captain", "-y") + Expect(err).NotTo(HaveOccurred()) + + mainAfterAllCrew, err := os.ReadFile(mainPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainAfterAllCrew)).NotTo(ContainSubstring("crewv1")) + Expect(string(mainAfterAllCrew)).NotTo(ContainSubstring("crewcontroller")) + Expect(string(mainAfterAllCrew)).To(ContainSubstring("shipv1"), + "ship group should remain") + + By("verifying group directories cleaned up") + Expect(kbc.HasFile("api/crew")).To(BeFalse()) + Expect(kbc.HasFile("internal/controller/crew")).To(BeFalse()) + Expect(kbc.HasFile("api/ship/v1/frigate_types.go")).To(BeTrue(), + "ship group should remain") + + By("deleting last API") + err = kbc.DeleteAPI("--group", "ship", "--version", "v1", "--kind", "Frigate", "-y") + Expect(err).NotTo(HaveOccurred()) + + projectFinal, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectFinal)).To(ContainSubstring("multigroup: true"), + "multigroup setting should remain") + Expect(string(projectFinal)).NotTo(ContainSubstring("resources:"), + "all resources should be removed") + }) + + It("should handle webhooks on resource-only APIs correctly", func() { + mainPath := filepath.Join(kbc.Dir, "cmd", "main.go") + mainBefore, err := os.ReadFile(mainPath) + Expect(err).NotTo(HaveOccurred()) + + By("creating resource without controller") + err = kbc.CreateAPI( + "--group", "config", + "--version", "v1", + "--kind", "Settings", + "--resource", + "--controller=false", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred()) + + By("adding webhooks to resource-only API") + err = kbc.CreateWebhook( + "--group", "config", + "--version", "v1", + "--kind", "Settings", + "--defaulting", + "--programmatic-validation", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred()) + + By("verifying webhook files created without controller") + Expect(kbc.HasFile("internal/webhook/v1/settings_webhook.go")).To(BeTrue()) + Expect(kbc.HasFile("internal/controller/settings_controller.go")).To(BeFalse()) + + By("deleting all webhooks first") + err = kbc.DeleteWebhook("--group", "config", "--version", "v1", "--kind", "Settings", "-y") + Expect(err).NotTo(HaveOccurred()) + + By("deleting resource-only API") + err = kbc.DeleteAPI("--group", "config", "--version", "v1", "--kind", "Settings", "-y") + Expect(err).NotTo(HaveOccurred()) + + By("verifying complete restoration to baseline") + mainAfterDelete, err := os.ReadFile(mainPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainAfterDelete)).To(Equal(string(mainBefore)), + "main.go should match initial baseline") + }) + + It("should handle multiple versions of same group correctly", func() { + mainPath := filepath.Join(kbc.Dir, "cmd", "main.go") + + By("creating v1 API") + err := kbc.CreateAPI( + "--group", "app", + "--version", "v1", + "--kind", "Database", + "--resource", + "--controller", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred()) + + By("creating v1alpha1 API of same group") + err = kbc.CreateAPI( + "--group", "app", + "--version", "v1alpha1", + "--kind", "Cache", + "--resource", + "--controller", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred()) + + By("verifying both versions exist") + Expect(kbc.HasFile("api/v1/database_types.go")).To(BeTrue()) + Expect(kbc.HasFile("api/v1alpha1/cache_types.go")).To(BeTrue()) + + mainWithBoth, err := os.ReadFile(mainPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainWithBoth)).To(ContainSubstring("appv1 ")) + Expect(string(mainWithBoth)).To(ContainSubstring("appv1alpha1 ")) + + By("deleting v1alpha1 (v1 should remain)") + err = kbc.DeleteAPI("--group", "app", "--version", "v1alpha1", "--kind", "Cache", "-y") + Expect(err).NotTo(HaveOccurred()) + + By("verifying v1 remains, v1alpha1 removed") + Expect(kbc.HasFile("api/v1/database_types.go")).To(BeTrue()) + Expect(kbc.HasFile("api/v1alpha1/cache_types.go")).To(BeFalse()) + + mainAfterDelete, err := os.ReadFile(mainPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainAfterDelete)).To(ContainSubstring("appv1 ")) + Expect(string(mainAfterDelete)).NotTo(ContainSubstring("appv1alpha1")) + + By("deleting v1") + err = kbc.DeleteAPI("--group", "app", "--version", "v1", "--kind", "Database", "-y") + Expect(err).NotTo(HaveOccurred()) + + By("verifying all API types removed") + Expect(kbc.HasFile("api/v1/database_types.go")).To(BeFalse()) + Expect(kbc.HasFile("api/v1alpha1/cache_types.go")).To(BeFalse()) + Expect(kbc.HasFile("internal/controller/database_controller.go")).To(BeFalse()) + Expect(kbc.HasFile("internal/controller/cache_controller.go")).To(BeFalse()) + + mainFinal, err := os.ReadFile(mainPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainFinal)).NotTo(ContainSubstring("appv1 ")) + Expect(string(mainFinal)).NotTo(ContainSubstring("appv1alpha1")) + Expect(string(mainFinal)).NotTo(ContainSubstring("DatabaseReconciler")) + Expect(string(mainFinal)).NotTo(ContainSubstring("CacheReconciler")) + }) +}) diff --git a/pkg/plugins/golang/v4/delete_webhook.go b/pkg/plugins/golang/v4/delete_webhook.go new file mode 100644 index 00000000000..bfd0e4ea74f --- /dev/null +++ b/pkg/plugins/golang/v4/delete_webhook.go @@ -0,0 +1,632 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v4 + +import ( + "bufio" + "fmt" + log "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/spf13/afero" + "github.com/spf13/pflag" + + "sigs.k8s.io/kubebuilder/v4/pkg/config" + "sigs.k8s.io/kubebuilder/v4/pkg/machinery" + "sigs.k8s.io/kubebuilder/v4/pkg/model/resource" + "sigs.k8s.io/kubebuilder/v4/pkg/plugin" + "sigs.k8s.io/kubebuilder/v4/pkg/plugin/util" + goPlugin "sigs.k8s.io/kubebuilder/v4/pkg/plugins/golang" +) + +var _ plugin.DeleteWebhookSubcommand = &deleteWebhookSubcommand{} + +type deleteWebhookSubcommand struct { + config config.Config + // For help text. + commandName string + + options *goPlugin.Options + + resource *resource.Resource + + // assumeYes skips interactive confirmation prompts + assumeYes bool + + // Webhook type flags - which webhook types to delete + doDefaulting bool + doValidation bool + doConversion bool + + // Deprecated - TODO: remove it for go/v5 + // isLegacyPath indicates that the webhook is in the legacy path under the api + isLegacyPath bool + + // Track what couldn't be automatically removed for manual cleanup instructions + manualCleanupWebhookImport bool + manualCleanupWebhookSetup bool +} + +func (p *deleteWebhookSubcommand) UpdateMetadata(cliMeta plugin.CLIMetadata, subcmdMeta *plugin.SubcommandMetadata) { + p.commandName = cliMeta.CommandName + + subcmdMeta.Description = `Delete webhook(s) for an API resource. + +Supports granular deletion: use flags to delete specific webhook types +(--defaulting, --programmatic-validation, --conversion). Without flags, +deletes all webhooks for the resource. + +Automatically removes: +- Webhook files (when all types deleted) +- Test files (when all types deleted) +- Kustomize manifests (certmanager, webhook service) +- PROJECT file webhook entries + +Requires manual cleanup (instructions provided after deletion): +- Webhook imports in cmd/main.go +- Webhook setup calls in cmd/main.go + +Note: Webhook files remain if other types still exist for the resource. +` + subcmdMeta.Examples = fmt.Sprintf( + ` # Delete all webhooks for the Memcached kind + %[1]s delete webhook --group cache --version v1alpha1 --kind Memcached + + # Delete only the defaulting webhook + %[1]s delete webhook --group cache --version v1alpha1 --kind Memcached --defaulting + + # Delete validation and defaulting webhooks, keep conversion + %[1]s delete webhook --group cache --version v1alpha1 --kind Memcached \ + --defaulting --programmatic-validation + + # Delete without confirmation prompt (use with caution) + %[1]s delete webhook --group cache --version v1alpha1 --kind Memcached -y + %[1]s delete webhook --group cache --version v1alpha1 --kind Memcached --yes +`, cliMeta.CommandName) +} + +func (p *deleteWebhookSubcommand) BindFlags(fs *pflag.FlagSet) { + if p.options == nil { + p.options = &goPlugin.Options{} + } + + fs.BoolVarP(&p.assumeYes, "yes", "y", false, + "proceed without prompting for confirmation") + + // Webhook type flags - same as create webhook for consistency + fs.BoolVar(&p.doDefaulting, "defaulting", false, + "delete defaulting webhook") + fs.BoolVar(&p.doValidation, "programmatic-validation", false, + "delete validation webhook") + fs.BoolVar(&p.doConversion, "conversion", false, + "delete conversion webhook") + + // Deprecated flag for backwards compatibility + fs.BoolVar(&p.isLegacyPath, "legacy-path", false, + "(Deprecated) indicates webhook is in legacy path under api/") +} + +func (p *deleteWebhookSubcommand) InjectConfig(c config.Config) error { + p.config = c + return nil +} + +func (p *deleteWebhookSubcommand) InjectResource(res *resource.Resource) error { + p.resource = res + + // For core/external types, match by GVK only, not domain + var configRes resource.Resource + var found bool + + resources, err := p.config.GetResources() + if err != nil { + return fmt.Errorf("failed to get resources: %w", err) + } + + for _, r := range resources { + if r.Group == p.resource.Group && r.Version == p.resource.Version && r.Kind == p.resource.Kind { + configRes = r + found = true + break + } + } + + if !found { + return fmt.Errorf("resource {%q %q %q} does not exist in the project", + p.resource.Group, p.resource.Version, p.resource.Kind) + } + + // Check if the resource has webhooks + if configRes.Webhooks == nil || configRes.Webhooks.IsEmpty() { + return fmt.Errorf("resource %q does not have any webhooks configured", p.resource.GVK) + } + + // If no specific webhook type flags are set, default to deleting all webhooks + if !p.doDefaulting && !p.doValidation && !p.doConversion { + p.doDefaulting = configRes.Webhooks.Defaulting + p.doValidation = configRes.Webhooks.Validation + p.doConversion = configRes.Webhooks.Conversion + log.Info("No specific webhook type specified, will delete all configured webhooks", + "defaulting", p.doDefaulting, "validation", p.doValidation, "conversion", p.doConversion) + } + + // Validate that the specified webhook types actually exist + if p.doDefaulting && !configRes.Webhooks.Defaulting { + return fmt.Errorf("resource %q does not have a defaulting webhook configured", p.resource.GVK) + } + if p.doValidation && !configRes.Webhooks.Validation { + return fmt.Errorf("resource %q does not have a validation webhook configured", p.resource.GVK) + } + if p.doConversion && !configRes.Webhooks.Conversion { + return fmt.Errorf("resource %q does not have a conversion webhook configured", p.resource.GVK) + } + + // Copy relevant fields from config resource + p.resource = &configRes + + return nil +} + +func (p *deleteWebhookSubcommand) Scaffold(fs machinery.Filesystem) error { + // Prompt for confirmation unless -y/--yes flag provided + if !p.assumeYes { + if !p.confirmDeletion() { + return fmt.Errorf("deletion cancelled by user") + } + } + + log.Info("Deleting webhook(s)...", + "defaulting", p.doDefaulting, + "validation", p.doValidation, + "conversion", p.doConversion) + + // Determine if webhook files should be deleted + shouldDeleteFiles := p.shouldDeleteWebhookFiles() + + deletedFiles, failedFiles := p.deleteWebhookFilesIfNeeded(fs, shouldDeleteFiles) + + // Also delete webhook suite test if appropriate + if shouldDeleteFiles && p.shouldDeleteWebhookSuiteTest() { + suiteDeleted := p.deleteWebhookSuiteTest(fs) + deletedFiles = append(deletedFiles, suiteDeleted...) + } + + // Build the new webhook configuration + newWebhooks := p.resource.Webhooks.Copy() + + if p.doDefaulting { + newWebhooks.Defaulting = false + newWebhooks.DefaultingPath = "" + } + if p.doValidation { + newWebhooks.Validation = false + newWebhooks.ValidationPath = "" + } + if p.doConversion { + newWebhooks.Conversion = false + newWebhooks.Spoke = nil + } + + // If all webhook types are now disabled, clear the entire webhooks struct + var webhooksToSet *resource.Webhooks + if !newWebhooks.Defaulting && !newWebhooks.Validation && !newWebhooks.Conversion { + webhooksToSet = nil + } else { + webhooksToSet = &newWebhooks + } + + // Use SetResourceWebhooks to properly replace (not merge) the webhook configuration + if err := p.config.SetResourceWebhooks(p.resource.GVK, webhooksToSet); err != nil { + return fmt.Errorf("failed to update resource webhooks in PROJECT file: %w", err) + } + + // Report results (kustomize cleanup is handled by kustomize plugin in the chain) + if len(failedFiles) > 0 { + return fmt.Errorf("failed to delete some files: %v", failedFiles) + } + + webhookTypes := []string{} + if p.doDefaulting { + webhookTypes = append(webhookTypes, "defaulting") + } + if p.doValidation { + webhookTypes = append(webhookTypes, "validation") + } + if p.doConversion { + webhookTypes = append(webhookTypes, "conversion") + } + + fmt.Printf("\nSuccessfully deleted %s webhook(s) for %s/%s (%s)\n", + strings.Join(webhookTypes, ", "), + p.resource.Group, p.resource.Version, p.resource.Kind) + if shouldDeleteFiles { + if len(deletedFiles) > 0 { + fmt.Printf("Deleted %d file(s)\n", len(deletedFiles)) + } + // Remove marker-based code from main.go when all webhooks are deleted + p.removeCodeFromMainGo(fs) + } else { + fmt.Println("Updated PROJECT file to remove webhook configuration") + fmt.Println("Note: Webhook implementation files were not deleted as other webhook types remain") + } + + return nil +} + +func (p *deleteWebhookSubcommand) PostScaffold() error { + shouldDeleteFiles := p.shouldDeleteWebhookFiles() + + if shouldDeleteFiles { + // Check if manual cleanup is needed + if p.manualCleanupWebhookImport || p.manualCleanupWebhookSetup { + fmt.Println() + fmt.Println("Manual cleanup required - automatic removal failed for:") + fmt.Println() + fmt.Println("In cmd/main.go:") + + repo := p.config.GetRepository() + webhookImportAlias := "webhook" + strings.ToLower(p.resource.Group) + p.resource.Version + if !p.config.IsMultiGroup() || p.resource.Group == "" { + webhookImportAlias = "webhook" + p.resource.Version + } + + if p.manualCleanupWebhookImport { + if p.config.IsMultiGroup() && p.resource.Group != "" { + fmt.Printf(" - Remove import: %s \"%s/internal/webhook/%s/%s\"\n", + webhookImportAlias, repo, p.resource.Group, p.resource.Version) + } else { + fmt.Printf(" - Remove import: %s \"%s/internal/webhook/%s\"\n", + webhookImportAlias, repo, p.resource.Version) + } + } + + if p.manualCleanupWebhookSetup { + fmt.Printf(" - Remove webhook setup block for: %s (see constant webhookSetupCodeFragment)\n", + p.resource.Kind) + } + } + + fmt.Println() + fmt.Println("Next steps:") + fmt.Println("$ go mod tidy") + fmt.Println("$ make generate") + fmt.Println("$ make manifests") + } else { + webhooksRemaining := []string{} + if p.resource.Webhooks.Defaulting && !p.doDefaulting { + webhooksRemaining = append(webhooksRemaining, "defaulting") + } + if p.resource.Webhooks.Validation && !p.doValidation { + webhooksRemaining = append(webhooksRemaining, "validation") + } + if p.resource.Webhooks.Conversion && !p.doConversion { + webhooksRemaining = append(webhooksRemaining, "conversion") + } + + // Build list of deleted webhook types for manual cleanup + deletedTypes := []string{} + if p.doDefaulting { + deletedTypes = append(deletedTypes, ".WithDefaulter()") + } + if p.doValidation { + deletedTypes = append(deletedTypes, ".WithValidator()") + } + if p.doConversion { + deletedTypes = append(deletedTypes, "conversion webhook setup") + } + + fmt.Printf("\nWebhook files kept (remaining types: %s).\n", strings.Join(webhooksRemaining, ", ")) + if len(deletedTypes) > 0 { + versionPath := p.resource.Version + if p.config.IsMultiGroup() && p.resource.Group != "" { + versionPath = p.resource.Group + "/" + p.resource.Version + } + webhookFile := fmt.Sprintf("internal/webhook/%s/%s_webhook.go", + versionPath, strings.ToLower(p.resource.Kind)) + fmt.Printf("Manual cleanup required in %s:\n", webhookFile) + fmt.Printf(" - Remove deleted webhook setup: %s\n", strings.Join(deletedTypes, ", ")) + } + fmt.Println() + fmt.Println("Next: update your webhook implementation and run:") + fmt.Println("$ make generate") + fmt.Println("$ make manifests") + } + + return nil +} + +func (p *deleteWebhookSubcommand) confirmDeletion() bool { + webhookTypes := []string{} + if p.doDefaulting { + webhookTypes = append(webhookTypes, "defaulting") + } + if p.doValidation { + webhookTypes = append(webhookTypes, "validation") + } + if p.doConversion { + webhookTypes = append(webhookTypes, "conversion") + } + + fmt.Printf("\nWarning: You are about to delete %s webhook(s) for %s/%s (%s)\n", + strings.Join(webhookTypes, ", "), + p.resource.Group, p.resource.Version, p.resource.Kind) + + fmt.Println("This will remove:") + + // Check if ALL webhook types will be removed + willHaveDefaulting := p.resource.Webhooks.Defaulting && !p.doDefaulting + willHaveValidation := p.resource.Webhooks.Validation && !p.doValidation + willHaveConversion := p.resource.Webhooks.Conversion && !p.doConversion + willRemoveAllWebhooks := !willHaveDefaulting && !willHaveValidation && !willHaveConversion + + if willRemoveAllWebhooks { + fmt.Println(" - Webhook implementation files") + fmt.Println(" - Webhook test files") + } + fmt.Printf(" - %s webhook configuration from PROJECT file\n", strings.Join(webhookTypes, ", ")) + + if !willRemoveAllWebhooks { + fmt.Println("\nNote: Webhook implementation files will NOT be deleted as other webhook types remain") + } + + fmt.Println("\nThis operation cannot be undone!") + fmt.Print("\nAre you sure you want to continue? [y/N]: ") + + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil { + log.Error("Failed to read user input", "error", err) + return false + } + + // Remove whitespace (handles both Unix \n and Windows \r\n) + response = strings.TrimSpace(response) + return response == "y" || response == "Y" || response == "yes" || response == "YES" +} + +// shouldDeleteWebhookFiles determines if webhook files should be deleted +// Files are deleted only if NO webhook types will remain for this resource +func (p *deleteWebhookSubcommand) shouldDeleteWebhookFiles() bool { + willHaveDefaulting := p.resource.Webhooks.Defaulting && !p.doDefaulting + willHaveValidation := p.resource.Webhooks.Validation && !p.doValidation + willHaveConversion := p.resource.Webhooks.Conversion && !p.doConversion + return !willHaveDefaulting && !willHaveValidation && !willHaveConversion +} + +// deleteWebhookFilesIfNeeded deletes webhook implementation files if shouldDelete is true +func (p *deleteWebhookSubcommand) deleteWebhookFilesIfNeeded( + fs machinery.Filesystem, shouldDelete bool, +) (deletedFiles, failedFiles []string) { + if !shouldDelete { + return nil, nil + } + + multigroup := p.config.IsMultiGroup() + kindLower := strings.ToLower(p.resource.Kind) + + // Get webhook file paths + webhookPath, webhookTestPath := p.getWebhookFilePaths(multigroup, kindLower) + filesToDelete := []string{webhookPath, webhookTestPath} + + // Delete each file + for _, file := range filesToDelete { + exists, err := afero.Exists(fs.FS, file) + if err != nil { + log.Warn("Error checking file existence", "file", file, "error", err) + continue + } + + if !exists { + log.Warn("File does not exist, skipping", "file", file) + continue + } + + if err := fs.FS.Remove(file); err != nil { + log.Error("Failed to delete file", "file", file, "error", err) + failedFiles = append(failedFiles, file) + } else { + log.Info("Deleted file", "file", file) + deletedFiles = append(deletedFiles, file) + } + } + + return deletedFiles, failedFiles +} + +// getWebhookFilePaths returns the paths to webhook implementation and test files +func (p *deleteWebhookSubcommand) getWebhookFilePaths(multigroup bool, kindLower string) (string, string) { + var webhookPath, webhookTestPath string + + if p.isLegacyPath { + // Legacy path: api//_webhook.go + if multigroup && p.resource.Group != "" { + webhookPath = filepath.Join("api", p.resource.Group, p.resource.Version, + fmt.Sprintf("%s_webhook.go", kindLower)) + webhookTestPath = filepath.Join("api", p.resource.Group, p.resource.Version, + fmt.Sprintf("%s_webhook_test.go", kindLower)) + } else { + webhookPath = filepath.Join("api", p.resource.Version, + fmt.Sprintf("%s_webhook.go", kindLower)) + webhookTestPath = filepath.Join("api", p.resource.Version, + fmt.Sprintf("%s_webhook_test.go", kindLower)) + } + } else { + // Standard path: internal/webhook//_webhook.go + if multigroup && p.resource.Group != "" { + webhookPath = filepath.Join("internal", "webhook", p.resource.Group, p.resource.Version, + fmt.Sprintf("%s_webhook.go", kindLower)) + webhookTestPath = filepath.Join("internal", "webhook", p.resource.Group, p.resource.Version, + fmt.Sprintf("%s_webhook_test.go", kindLower)) + } else { + webhookPath = filepath.Join("internal", "webhook", p.resource.Version, + fmt.Sprintf("%s_webhook.go", kindLower)) + webhookTestPath = filepath.Join("internal", "webhook", p.resource.Version, + fmt.Sprintf("%s_webhook_test.go", kindLower)) + } + } + + return webhookPath, webhookTestPath +} + +// willBeLastWebhookAfterDeletion checks if there will be any webhooks remaining after this deletion +// It checks: +// 1. Will the current resource have any webhooks left after deletion? +// 2. Do any other resources have webhooks? +func (p *deleteWebhookSubcommand) willBeLastWebhookAfterDeletion() (bool, error) { + resources, err := p.config.GetResources() + if err != nil { + return false, fmt.Errorf("failed to get resources from config: %w", err) + } + + // Check if current resource will have webhooks remaining after deletion + currentResourceWillHaveWebhooks := false + if p.resource.Webhooks != nil { + willHaveDefaulting := p.resource.Webhooks.Defaulting && !p.doDefaulting + willHaveValidation := p.resource.Webhooks.Validation && !p.doValidation + willHaveConversion := p.resource.Webhooks.Conversion && !p.doConversion + currentResourceWillHaveWebhooks = willHaveDefaulting || willHaveValidation || willHaveConversion + } + + if currentResourceWillHaveWebhooks { + // Current resource will still have webhooks, so definitely not the last + return false, nil + } + + // Check if any other resources have webhooks + for _, res := range resources { + // Skip the current resource (we already know it won't have webhooks after deletion) + if res.IsEqualTo(p.resource.GVK) { + continue + } + + // Check if this other resource has webhooks + if res.Webhooks != nil && !res.Webhooks.IsEmpty() { + return false, nil + } + } + + // No webhooks will remain anywhere + return true, nil +} + +// deleteWebhookSuiteTest deletes the webhook_suite_test.go file for this version +func (p *deleteWebhookSubcommand) deleteWebhookSuiteTest(fs machinery.Filesystem) []string { + deleted := []string{} + + var suiteTestPath string + if p.config.IsMultiGroup() && p.resource.Group != "" { + suiteTestPath = filepath.Join("internal", "webhook", p.resource.Group, + p.resource.Version, "webhook_suite_test.go") + } else { + suiteTestPath = filepath.Join("internal", "webhook", p.resource.Version, + "webhook_suite_test.go") + } + + if exists, _ := afero.Exists(fs.FS, suiteTestPath); exists { + if err := fs.FS.Remove(suiteTestPath); err != nil { + log.Warn("Failed to delete webhook suite test", "file", suiteTestPath, "error", err) + } else { + log.Info("Deleted webhook suite test", "file", suiteTestPath) + deleted = append(deleted, suiteTestPath) + } + } + + return deleted +} + +// shouldDeleteWebhookSuiteTest checks if the webhook_suite_test.go should be deleted +// It should be deleted if no other resources in this version have webhooks +func (p *deleteWebhookSubcommand) shouldDeleteWebhookSuiteTest() bool { + resources, err := p.config.GetResources() + if err != nil { + return false + } + + for _, res := range resources { + // Skip current resource + if res.Group == p.resource.Group && res.Version == p.resource.Version && res.Kind == p.resource.Kind { + continue + } + + // Check if resource is in same version and has webhooks + if res.Version == p.resource.Version && res.Webhooks != nil && !res.Webhooks.IsEmpty() { + return false + } + } + + return true +} + +// Webhook code fragments to remove from main.go (match the format used in create) +const ( + webhookImportCodeFragment = `%s "%s/internal/webhook/%s" +` + multiGroupWebhookImportCodeFragment = `%s "%s/internal/webhook/%s/%s" +` + webhookSetupCodeFragment = `// nolint:goconst + if os.Getenv("ENABLE_WEBHOOKS") != "false" { + if err := %s.Setup%sWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "Failed to create webhook", "webhook", "%s") + os.Exit(1) + } + } +` +) + +// removeCodeFromMainGo removes marker-inserted webhook code from cmd/main.go +func (p *deleteWebhookSubcommand) removeCodeFromMainGo(_ machinery.Filesystem) { + mainPath := filepath.Join("cmd", "main.go") + + repo := p.config.GetRepository() + + webhookImportAlias := "webhook" + strings.ToLower(p.resource.Group) + p.resource.Version + if !p.config.IsMultiGroup() || p.resource.Group == "" { + webhookImportAlias = "webhook" + p.resource.Version + } + + // Remove webhook import + var webhookImport string + if p.config.IsMultiGroup() && p.resource.Group != "" { + webhookImport = fmt.Sprintf(multiGroupWebhookImportCodeFragment, + webhookImportAlias, repo, p.resource.Group, p.resource.Version) + } else { + webhookImport = fmt.Sprintf(webhookImportCodeFragment, + webhookImportAlias, repo, p.resource.Version) + } + + if err := util.ReplaceInFile(mainPath, "\t"+webhookImport, ""); err != nil { + log.Warn("Unable to remove webhook import from main.go - manual cleanup needed", + "import", webhookImportAlias, "error", err) + p.manualCleanupWebhookImport = true + } else { + log.Info("Removed webhook import from main.go", "import", webhookImportAlias) + } + + // Remove webhook setup code + webhookSetup := fmt.Sprintf("\t"+webhookSetupCodeFragment, + webhookImportAlias, p.resource.Kind, p.resource.Kind) + + if err := util.ReplaceInFile(mainPath, webhookSetup, ""); err != nil { + log.Warn("Unable to remove webhook setup from main.go - manual cleanup needed", + "webhook", p.resource.Kind, "error", err) + p.manualCleanupWebhookSetup = true + } else { + log.Info("Removed webhook setup from main.go", "webhook", p.resource.Kind) + } +} diff --git a/pkg/plugins/golang/v4/delete_webhook_test.go b/pkg/plugins/golang/v4/delete_webhook_test.go new file mode 100644 index 00000000000..d5356479fbb --- /dev/null +++ b/pkg/plugins/golang/v4/delete_webhook_test.go @@ -0,0 +1,283 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v4 + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "sigs.k8s.io/kubebuilder/v4/pkg/config" + cfgv3 "sigs.k8s.io/kubebuilder/v4/pkg/config/v3" + "sigs.k8s.io/kubebuilder/v4/pkg/model/resource" +) + +var _ = Describe("DeleteWebhook", func() { + Context("InjectResource", func() { + var ( + subcmd deleteWebhookSubcommand + cfg config.Config + ) + + BeforeEach(func() { + cfg = cfgv3.New() + subcmd = deleteWebhookSubcommand{ + config: cfg, + } + }) + + It("should fail if resource does not exist in config", func() { + res := &resource.Resource{ + GVK: resource.GVK{ + Group: "crew", + Version: "v1", + Kind: "Captain", + }, + } + + err := subcmd.InjectResource(res) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not exist")) + }) + + It("should fail if resource has no webhooks", func() { + res := resource.Resource{ + GVK: resource.GVK{ + Group: "crew", + Version: "v1", + Kind: "Captain", + }, + API: &resource.API{ + CRDVersion: "v1", + }, + } + + Expect(cfg.AddResource(res)).To(Succeed()) + + err := subcmd.InjectResource(&res) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not have any webhooks")) + }) + + It("should default to all webhooks when no flags specified", func() { + res := resource.Resource{ + GVK: resource.GVK{ + Group: "crew", + Version: "v1", + Kind: "Captain", + }, + API: &resource.API{ + CRDVersion: "v1", + }, + Webhooks: &resource.Webhooks{ + WebhookVersion: "v1", + Defaulting: true, + Validation: true, + }, + } + + Expect(cfg.AddResource(res)).To(Succeed()) + + err := subcmd.InjectResource(&res) + Expect(err).NotTo(HaveOccurred()) + Expect(subcmd.resource).NotTo(BeNil()) + Expect(subcmd.doDefaulting).To(BeTrue()) + Expect(subcmd.doValidation).To(BeTrue()) + Expect(subcmd.doConversion).To(BeFalse()) + }) + + It("should fail if specified webhook type doesn't exist", func() { + res := resource.Resource{ + GVK: resource.GVK{ + Group: "crew", + Version: "v1", + Kind: "Captain", + }, + API: &resource.API{ + CRDVersion: "v1", + }, + Webhooks: &resource.Webhooks{ + WebhookVersion: "v1", + Validation: true, + }, + } + + Expect(cfg.AddResource(res)).To(Succeed()) + subcmd.doDefaulting = true // Request to delete defaulting, but it doesn't exist + + err := subcmd.InjectResource(&res) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not have a defaulting webhook")) + }) + + It("should succeed when deleting specific webhook type", func() { + res := resource.Resource{ + GVK: resource.GVK{ + Group: "crew", + Version: "v1", + Kind: "Captain", + }, + API: &resource.API{ + CRDVersion: "v1", + }, + Webhooks: &resource.Webhooks{ + WebhookVersion: "v1", + Defaulting: true, + Validation: true, + }, + } + + Expect(cfg.AddResource(res)).To(Succeed()) + subcmd.doValidation = true // Only delete validation webhook + + err := subcmd.InjectResource(&res) + Expect(err).NotTo(HaveOccurred()) + Expect(subcmd.resource).NotTo(BeNil()) + Expect(subcmd.doValidation).To(BeTrue()) + Expect(subcmd.doDefaulting).To(BeFalse()) + }) + }) + + Context("willBeLastWebhookAfterDeletion", func() { + var ( + subcmd deleteWebhookSubcommand + cfg config.Config + ) + + BeforeEach(func() { + cfg = cfgv3.New() + subcmd = deleteWebhookSubcommand{ + config: cfg, + } + }) + + It("should return true when deleting the only webhook", func() { + res := resource.Resource{ + GVK: resource.GVK{ + Group: "crew", + Version: "v1", + Kind: "Captain", + }, + Webhooks: &resource.Webhooks{ + WebhookVersion: "v1", + Defaulting: true, + }, + } + + Expect(cfg.AddResource(res)).To(Succeed()) + subcmd.resource = &res + subcmd.doDefaulting = true + + isLast, err := subcmd.willBeLastWebhookAfterDeletion() + Expect(err).NotTo(HaveOccurred()) + Expect(isLast).To(BeTrue()) + }) + + It("should return false when deleting one webhook type but others remain", func() { + res := resource.Resource{ + GVK: resource.GVK{ + Group: "crew", + Version: "v1", + Kind: "Captain", + }, + Webhooks: &resource.Webhooks{ + WebhookVersion: "v1", + Defaulting: true, + Validation: true, + }, + } + + Expect(cfg.AddResource(res)).To(Succeed()) + subcmd.resource = &res + subcmd.doDefaulting = true // Only deleting defaulting, validation remains + + isLast, err := subcmd.willBeLastWebhookAfterDeletion() + Expect(err).NotTo(HaveOccurred()) + Expect(isLast).To(BeFalse()) + }) + + It("should return false when multiple resources have webhooks", func() { + res1 := resource.Resource{ + GVK: resource.GVK{ + Group: "crew", + Version: "v1", + Kind: "Captain", + }, + Webhooks: &resource.Webhooks{ + WebhookVersion: "v1", + Defaulting: true, + }, + } + + res2 := resource.Resource{ + GVK: resource.GVK{ + Group: "crew", + Version: "v1", + Kind: "FirstMate", + }, + Webhooks: &resource.Webhooks{ + WebhookVersion: "v1", + Validation: true, + }, + } + + Expect(cfg.AddResource(res1)).To(Succeed()) + Expect(cfg.AddResource(res2)).To(Succeed()) + subcmd.resource = &res1 + subcmd.doDefaulting = true + + isLast, err := subcmd.willBeLastWebhookAfterDeletion() + Expect(err).NotTo(HaveOccurred()) + Expect(isLast).To(BeFalse()) + }) + + It("should return true when deleting all webhook types and no other resources have webhooks", func() { + res1 := resource.Resource{ + GVK: resource.GVK{ + Group: "crew", + Version: "v1", + Kind: "Captain", + }, + Webhooks: &resource.Webhooks{ + WebhookVersion: "v1", + Defaulting: true, + Validation: true, + }, + } + + res2 := resource.Resource{ + GVK: resource.GVK{ + Group: "crew", + Version: "v1", + Kind: "FirstMate", + }, + API: &resource.API{ + CRDVersion: "v1", + }, + } + + Expect(cfg.AddResource(res1)).To(Succeed()) + Expect(cfg.AddResource(res2)).To(Succeed()) + subcmd.resource = &res1 + subcmd.doDefaulting = true + subcmd.doValidation = true + + isLast, err := subcmd.willBeLastWebhookAfterDeletion() + Expect(err).NotTo(HaveOccurred()) + Expect(isLast).To(BeTrue()) + }) + }) +}) diff --git a/pkg/plugins/golang/v4/plugin.go b/pkg/plugins/golang/v4/plugin.go index 351a6655385..edb6d0fac5c 100644 --- a/pkg/plugins/golang/v4/plugin.go +++ b/pkg/plugins/golang/v4/plugin.go @@ -31,13 +31,19 @@ var ( supportedProjectVersions = []config.Version{cfgv3.Version} ) -var _ plugin.Full = Plugin{} +var ( + _ plugin.Full = Plugin{} + _ plugin.DeleteAPI = Plugin{} + _ plugin.DeleteWebhook = Plugin{} +) // Plugin implements the plugin.Full interface type Plugin struct { initSubcommand createAPISubcommand createWebhookSubcommand + deleteAPISubcommand + deleteWebhookSubcommand editSubcommand } @@ -61,6 +67,14 @@ func (p Plugin) GetCreateWebhookSubcommand() plugin.CreateWebhookSubcommand { return &p.createWebhookSubcommand } +// GetDeleteAPISubcommand will return the subcommand which is responsible for deleting apis +func (p Plugin) GetDeleteAPISubcommand() plugin.DeleteAPISubcommand { return &p.deleteAPISubcommand } + +// GetDeleteWebhookSubcommand will return the subcommand which is responsible for deleting webhooks +func (p Plugin) GetDeleteWebhookSubcommand() plugin.DeleteWebhookSubcommand { + return &p.deleteWebhookSubcommand +} + // GetEditSubcommand will return the subcommand which is responsible for editing the scaffold of the project func (p Plugin) GetEditSubcommand() plugin.EditSubcommand { return &p.editSubcommand } diff --git a/pkg/plugins/optional/autoupdate/v1alpha/delete_integration_test.go b/pkg/plugins/optional/autoupdate/v1alpha/delete_integration_test.go new file mode 100644 index 00000000000..7caa93c2dde --- /dev/null +++ b/pkg/plugins/optional/autoupdate/v1alpha/delete_integration_test.go @@ -0,0 +1,80 @@ +// Copyright 2026 The Kubernetes Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build integration + +package v1alpha + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "sigs.k8s.io/kubebuilder/v4/pkg/plugin/util" + "sigs.k8s.io/kubebuilder/v4/test/e2e/utils" +) + +var _ = Describe("Autoupdate Plugin Delete", func() { + var kbc *utils.TestContext + + BeforeEach(func() { + var err error + kbc, err = utils.NewTestContext(util.KubebuilderBinName, "GO111MODULE=on") + Expect(err).NotTo(HaveOccurred()) + Expect(kbc.Prepare()).To(Succeed()) + + err = kbc.Init("--plugins", "go/v4", "--domain", kbc.Domain, "--skip-go-version-check") + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + kbc.Destroy() + }) + + It("should completely undo autoupdate edit - before state equals after delete", func() { + By("verifying baseline PROJECT file is clean") + projectPath := filepath.Join(kbc.Dir, "PROJECT") + projectBefore, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectBefore)).NotTo(ContainSubstring("autoupdate.kubebuilder.io/v1-alpha"), + "baseline PROJECT should not contain autoupdate plugin config") + + By("adding autoupdate workflow") + err = kbc.Edit("--plugins=autoupdate/v1-alpha") + Expect(err).NotTo(HaveOccurred()) + + By("verifying autoupdate workflow was created") + Expect(kbc.HasFile(".github/workflows/auto_update.yml")).To(BeTrue()) + + By("verifying PROJECT file was updated") + projectAfterCreate, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectAfterCreate)).To(ContainSubstring("autoupdate.kubebuilder.io/v1-alpha")) + + By("deleting autoupdate workflow") + err = kbc.Delete("--plugins=autoupdate/v1-alpha") + Expect(err).NotTo(HaveOccurred()) + + By("verifying autoupdate workflow was deleted") + Expect(kbc.HasFile(".github/workflows/auto_update.yml")).To(BeFalse()) + + By("verifying PROJECT file matches initial state") + projectAfterDelete, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectAfterDelete)).NotTo(ContainSubstring("autoupdate.kubebuilder.io/v1-alpha"), + "autoupdate plugin config should be removed") + }) +}) diff --git a/pkg/plugins/optional/autoupdate/v1alpha/edit.go b/pkg/plugins/optional/autoupdate/v1alpha/edit.go index 6983b7fc0a7..9cfaba720e8 100644 --- a/pkg/plugins/optional/autoupdate/v1alpha/edit.go +++ b/pkg/plugins/optional/autoupdate/v1alpha/edit.go @@ -19,7 +19,9 @@ package v1alpha import ( "fmt" log "log/slog" + "path/filepath" + "github.com/spf13/afero" "github.com/spf13/pflag" "sigs.k8s.io/kubebuilder/v4/pkg/config" @@ -33,6 +35,7 @@ var _ plugin.EditSubcommand = &editSubcommand{} type editSubcommand struct { config config.Config useGHModels bool + delete bool } func (p *editSubcommand) UpdateMetadata(cliMeta plugin.CLIMetadata, subcmdMeta *plugin.SubcommandMetadata) { @@ -49,6 +52,7 @@ func (p *editSubcommand) UpdateMetadata(cliMeta plugin.CLIMetadata, subcmdMeta * func (p *editSubcommand) BindFlags(fs *pflag.FlagSet) { fs.BoolVar(&p.useGHModels, "use-gh-models", false, "enable GitHub Models AI summary in the scaffolded workflow (requires GitHub Models permissions)") + fs.BoolVar(&p.delete, "delete", false, "delete auto-update workflow from the project") } func (p *editSubcommand) InjectConfig(c config.Config) error { @@ -68,6 +72,10 @@ func (p *editSubcommand) PreScaffold(machinery.Filesystem) error { } func (p *editSubcommand) Scaffold(fs machinery.Filesystem) error { + if p.delete { + return p.deleteAutoUpdateWorkflow(fs) + } + if err := insertPluginMetaToConfig(p.config, PluginConfig{UseGHModels: p.useGHModels}); err != nil { return fmt.Errorf("error inserting project plugin meta to configuration: %w", err) } @@ -81,6 +89,41 @@ func (p *editSubcommand) Scaffold(fs machinery.Filesystem) error { return nil } +func (p *editSubcommand) deleteAutoUpdateWorkflow(fs machinery.Filesystem) error { + log.Info("Deleting auto-update workflow...") + + workflowFile := filepath.Join(".github", "workflows", "auto_update.yml") + + if exists, _ := afero.Exists(fs.FS, workflowFile); exists { + if err := fs.FS.Remove(workflowFile); err != nil { + log.Warn("Failed to delete workflow file", "file", workflowFile, "error", err) + } else { + log.Info("Deleted workflow file", "file", workflowFile) + fmt.Println("\nDeleted auto-update workflow") + } + } else { + log.Warn("Workflow file not found", "file", workflowFile) + fmt.Println("\nWorkflow file not found (may have been already deleted)") + } + + // Remove plugin config from PROJECT by encoding empty struct + // Empty struct will be omitted from YAML during marshaling + key := plugin.GetPluginKeyForConfig(p.config.GetPluginChain(), Plugin{}) + if err := p.config.EncodePluginConfig(key, struct{}{}); err != nil { + canonicalKey := plugin.KeyFor(Plugin{}) + if key != canonicalKey { + if err2 := p.config.EncodePluginConfig(canonicalKey, struct{}{}); err2 != nil { + log.Warn("Failed to remove plugin configuration from PROJECT file", + "provided_key_error", err, "canonical_key_error", err2) + } + } else { + log.Warn("Failed to remove plugin config", "error", err) + } + } + + return nil +} + func (p *editSubcommand) PostScaffold() error { // Inform users about GitHub Models if they didn't enable it if !p.useGHModels { diff --git a/pkg/plugins/optional/autoupdate/v1alpha/plugin.go b/pkg/plugins/optional/autoupdate/v1alpha/plugin.go index c927f46f7a4..c0c608d9548 100644 --- a/pkg/plugins/optional/autoupdate/v1alpha/plugin.go +++ b/pkg/plugins/optional/autoupdate/v1alpha/plugin.go @@ -63,7 +63,10 @@ type Plugin struct { initSubcommand } -var _ plugin.Init = Plugin{} +var ( + _ plugin.Init = Plugin{} + _ plugin.HasDeleteSupport = Plugin{} +) // PluginConfig defines the structure that will be used to track the data type PluginConfig struct { @@ -85,6 +88,9 @@ func (p Plugin) GetEditSubcommand() plugin.EditSubcommand { return &p.editSubcom // GetInitSubcommand will return the subcommand which is responsible for init autoupdate plugin func (p Plugin) GetInitSubcommand() plugin.InitSubcommand { return &p.initSubcommand } +// SupportsDelete returns true indicating this plugin supports deletion via Edit --delete +func (Plugin) SupportsDelete() bool { return true } + // Description returns a short description of the plugin func (Plugin) Description() string { return "Proposes Kubebuilder scaffold updates via GitHub Actions" diff --git a/pkg/plugins/optional/grafana/v1alpha/delete_integration_test.go b/pkg/plugins/optional/grafana/v1alpha/delete_integration_test.go new file mode 100644 index 00000000000..9cf223466fa --- /dev/null +++ b/pkg/plugins/optional/grafana/v1alpha/delete_integration_test.go @@ -0,0 +1,83 @@ +// Copyright 2026 The Kubernetes Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build integration + +package v1alpha + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "sigs.k8s.io/kubebuilder/v4/pkg/plugin/util" + "sigs.k8s.io/kubebuilder/v4/test/e2e/utils" +) + +var _ = Describe("Grafana Plugin Delete", func() { + var kbc *utils.TestContext + + BeforeEach(func() { + var err error + kbc, err = utils.NewTestContext(util.KubebuilderBinName, "GO111MODULE=on") + Expect(err).NotTo(HaveOccurred()) + Expect(kbc.Prepare()).To(Succeed()) + + err = kbc.Init("--plugins", "go/v4", "--domain", kbc.Domain, "--skip-go-version-check") + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + kbc.Destroy() + }) + + It("should completely undo grafana edit - before state equals after delete", func() { + By("verifying baseline PROJECT file is clean") + projectPath := filepath.Join(kbc.Dir, "PROJECT") + projectBefore, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectBefore)).NotTo(ContainSubstring("grafana.kubebuilder.io/v1-alpha"), + "baseline PROJECT should not contain grafana plugin config") + + By("adding grafana manifests") + err = kbc.Edit("--plugins=grafana/v1-alpha") + Expect(err).NotTo(HaveOccurred()) + + By("verifying grafana files were created") + Expect(kbc.HasFile("grafana/controller-runtime-metrics.json")).To(BeTrue()) + Expect(kbc.HasFile("grafana/custom-metrics/config.yaml")).To(BeTrue()) + + By("verifying PROJECT file was updated") + projectAfterCreate, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectAfterCreate)).To(ContainSubstring("grafana.kubebuilder.io/v1-alpha")) + + By("deleting grafana manifests") + err = kbc.Delete("--plugins=grafana/v1-alpha") + Expect(err).NotTo(HaveOccurred()) + + By("verifying grafana files were deleted") + Expect(kbc.HasFile("grafana/controller-runtime-metrics.json")).To(BeFalse()) + Expect(kbc.HasFile("grafana/custom-metrics/config.yaml")).To(BeFalse()) + Expect(kbc.HasFile("grafana")).To(BeFalse()) + + By("verifying PROJECT file matches initial state") + projectAfterDelete, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectAfterDelete)).NotTo(ContainSubstring("grafana.kubebuilder.io/v1-alpha"), + "grafana plugin config should be removed") + }) +}) diff --git a/pkg/plugins/optional/grafana/v1alpha/edit.go b/pkg/plugins/optional/grafana/v1alpha/edit.go index 90c4180efb1..134a0eedb8a 100644 --- a/pkg/plugins/optional/grafana/v1alpha/edit.go +++ b/pkg/plugins/optional/grafana/v1alpha/edit.go @@ -14,11 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -//nolint:dupl package v1alpha import ( "fmt" + "log/slog" + "path/filepath" + + "github.com/spf13/afero" + "github.com/spf13/pflag" "sigs.k8s.io/kubebuilder/v4/pkg/config" "sigs.k8s.io/kubebuilder/v4/pkg/machinery" @@ -30,6 +34,7 @@ var _ plugin.EditSubcommand = &editSubcommand{} type editSubcommand struct { config config.Config + delete bool } func (p *editSubcommand) UpdateMetadata(cliMeta plugin.CLIMetadata, subcmdMeta *plugin.SubcommandMetadata) { @@ -40,12 +45,20 @@ func (p *editSubcommand) UpdateMetadata(cliMeta plugin.CLIMetadata, subcmdMeta * `, cliMeta.CommandName, plugin.KeyFor(Plugin{})) } +func (p *editSubcommand) BindFlags(fs *pflag.FlagSet) { + fs.BoolVar(&p.delete, "delete", false, "delete Grafana manifests from the project") +} + func (p *editSubcommand) InjectConfig(c config.Config) error { p.config = c return nil } func (p *editSubcommand) Scaffold(fs machinery.Filesystem) error { + if p.delete { + return p.deleteGrafanaManifests(fs) + } + if err := InsertPluginMetaToConfig(p.config, pluginConfig{}); err != nil { return fmt.Errorf("error inserting project plugin meta to configuration: %w", err) } @@ -58,3 +71,52 @@ func (p *editSubcommand) Scaffold(fs machinery.Filesystem) error { return nil } + +func (p *editSubcommand) deleteGrafanaManifests(fs machinery.Filesystem) error { + slog.Info("Deleting Grafana manifests...") + + grafanaFiles := []string{ + filepath.Join("grafana", "controller-runtime-metrics.json"), + filepath.Join("grafana", "custom-metrics", "config.yaml"), + filepath.Join("grafana", "custom-metrics", "custom-metrics-dashboard.json"), + } + + deletedCount := 0 + for _, file := range grafanaFiles { + if exists, _ := afero.Exists(fs.FS, file); exists { + if err := fs.FS.Remove(file); err != nil { + slog.Warn("Failed to delete Grafana file", "file", file, "error", err) + } else { + slog.Info("Deleted Grafana file", "file", file) + deletedCount++ + } + } + } + + // Try to remove directories (using RemoveAll to handle non-empty directories) + if exists, _ := afero.DirExists(fs.FS, "grafana/custom-metrics"); exists { + _ = fs.FS.RemoveAll("grafana/custom-metrics") + } + if exists, _ := afero.DirExists(fs.FS, "grafana"); exists { + _ = fs.FS.RemoveAll("grafana") + } + + // Remove plugin config from PROJECT by encoding empty struct + key := plugin.GetPluginKeyForConfig(p.config.GetPluginChain(), Plugin{}) + if err := p.config.EncodePluginConfig(key, struct{}{}); err != nil { + canonicalKey := plugin.KeyFor(Plugin{}) + if key != canonicalKey { + if err2 := p.config.EncodePluginConfig(canonicalKey, struct{}{}); err2 != nil { + slog.Warn("Failed to remove plugin configuration from PROJECT file", + "provided_key_error", err, "canonical_key_error", err2) + } + } else { + slog.Warn("Failed to remove plugin config", "error", err) + } + } + + fmt.Printf("\nDeleted %d Grafana manifest(s)\n", deletedCount) + fmt.Println("Grafana dashboards removed from project") + + return nil +} diff --git a/pkg/plugins/optional/grafana/v1alpha/init.go b/pkg/plugins/optional/grafana/v1alpha/init.go index fa400fdf8f0..487455f36e4 100644 --- a/pkg/plugins/optional/grafana/v1alpha/init.go +++ b/pkg/plugins/optional/grafana/v1alpha/init.go @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -//nolint:dupl package v1alpha import ( diff --git a/pkg/plugins/optional/grafana/v1alpha/plugin.go b/pkg/plugins/optional/grafana/v1alpha/plugin.go index a3e250328f7..34b2a172ca8 100644 --- a/pkg/plugins/optional/grafana/v1alpha/plugin.go +++ b/pkg/plugins/optional/grafana/v1alpha/plugin.go @@ -37,7 +37,10 @@ type Plugin struct { editSubcommand } -var _ plugin.Init = Plugin{} +var ( + _ plugin.Init = Plugin{} + _ plugin.HasDeleteSupport = Plugin{} +) // Name returns the name of the plugin func (Plugin) Name() string { return pluginName } @@ -54,6 +57,9 @@ func (p Plugin) GetInitSubcommand() plugin.InitSubcommand { return &p.initSubcom // GetEditSubcommand will return the subcommand which is responsible for adding grafana manifests func (p Plugin) GetEditSubcommand() plugin.EditSubcommand { return &p.editSubcommand } +// SupportsDelete returns true indicating this plugin supports deletion via Edit --delete +func (Plugin) SupportsDelete() bool { return true } + type pluginConfig struct{} // Description returns a short description of the plugin diff --git a/pkg/plugins/optional/grafana/v1alpha/suite_test.go b/pkg/plugins/optional/grafana/v1alpha/suite_test.go new file mode 100644 index 00000000000..59cad372e1a --- /dev/null +++ b/pkg/plugins/optional/grafana/v1alpha/suite_test.go @@ -0,0 +1,29 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestGrafanaV1Alpha(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Grafana V1Alpha Plugin Suite") +} diff --git a/pkg/plugins/optional/helm/v2alpha/delete_integration_test.go b/pkg/plugins/optional/helm/v2alpha/delete_integration_test.go new file mode 100644 index 00000000000..5ab21da1426 --- /dev/null +++ b/pkg/plugins/optional/helm/v2alpha/delete_integration_test.go @@ -0,0 +1,101 @@ +// Copyright 2026 The Kubernetes Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build integration + +package v2alpha + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "sigs.k8s.io/kubebuilder/v4/pkg/plugin/util" + "sigs.k8s.io/kubebuilder/v4/test/e2e/utils" +) + +var _ = Describe("Helm Plugin Delete", func() { + var kbc *utils.TestContext + + BeforeEach(func() { + var err error + kbc, err = utils.NewTestContext(util.KubebuilderBinName, "GO111MODULE=on") + Expect(err).NotTo(HaveOccurred()) + Expect(kbc.Prepare()).To(Succeed()) + + err = kbc.Init("--plugins", "go/v4", "--domain", kbc.Domain, "--skip-go-version-check") + Expect(err).NotTo(HaveOccurred()) + + // Create a simple API so we have manifests to convert to Helm + err = kbc.CreateAPI( + "--group", "crew", + "--version", "v1", + "--kind", "Captain", + "--resource", + "--controller", + "--make=false", + ) + Expect(err).NotTo(HaveOccurred()) + + // Generate install.yaml for helm to process + err = kbc.Make("build-installer") + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + kbc.Destroy() + }) + + It("should completely undo helm edit - before state equals after delete", func() { + By("verifying baseline PROJECT file is clean") + projectPath := filepath.Join(kbc.Dir, "PROJECT") + projectBefore, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectBefore)).NotTo(ContainSubstring("helm.kubebuilder.io/v2-alpha"), + "baseline PROJECT should not contain helm plugin config") + + By("generating helm chart") + err = kbc.Edit("--plugins=helm/v2-alpha") + Expect(err).NotTo(HaveOccurred()) + + By("verifying helm chart files were created") + Expect(kbc.HasFile("dist/chart/Chart.yaml")).To(BeTrue()) + Expect(kbc.HasFile("dist/chart/values.yaml")).To(BeTrue()) + Expect(kbc.HasFile("dist/chart/templates/_helpers.tpl")).To(BeTrue()) + Expect(kbc.HasFile(".github/workflows/test-chart.yml")).To(BeTrue()) + + By("verifying PROJECT file was updated") + projectAfterCreate, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectAfterCreate)).To(ContainSubstring("helm.kubebuilder.io/v2-alpha")) + + By("deleting helm chart") + err = kbc.Delete("--plugins=helm/v2-alpha") + Expect(err).NotTo(HaveOccurred()) + + By("verifying helm chart files were deleted") + Expect(kbc.HasFile("dist/chart/Chart.yaml")).To(BeFalse()) + Expect(kbc.HasFile("dist/chart/values.yaml")).To(BeFalse()) + Expect(kbc.HasFile("dist/chart")).To(BeFalse()) + Expect(kbc.HasFile(".github/workflows/test-chart.yml")).To(BeFalse()) + + By("verifying PROJECT file matches initial state") + projectAfterDelete, err := os.ReadFile(projectPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(projectAfterDelete)).NotTo(ContainSubstring("helm.kubebuilder.io/v2-alpha"), + "helm plugin config should be removed") + }) +}) diff --git a/pkg/plugins/optional/helm/v2alpha/edit.go b/pkg/plugins/optional/helm/v2alpha/edit.go index 5bd8466ac44..9030978912b 100644 --- a/pkg/plugins/optional/helm/v2alpha/edit.go +++ b/pkg/plugins/optional/helm/v2alpha/edit.go @@ -25,6 +25,7 @@ import ( "path/filepath" "strings" + "github.com/spf13/afero" "github.com/spf13/pflag" "go.yaml.in/yaml/v3" @@ -52,6 +53,7 @@ type editSubcommand struct { force bool manifestsFile string outputDir string + delete bool // Delete flag to remove Helm chart generation } //nolint:lll @@ -106,6 +108,7 @@ func (p *editSubcommand) BindFlags(fs *pflag.FlagSet) { fs.StringVar(&p.manifestsFile, "manifests", DefaultManifestsFile, "path to the YAML file containing Kubernetes manifests from kustomize output") fs.StringVar(&p.outputDir, "output-dir", DefaultOutputDir, "output directory for the generated Helm chart") + fs.BoolVar(&p.delete, "delete", false, "delete Helm chart generation from the project") } func (p *editSubcommand) InjectConfig(c config.Config) error { @@ -114,6 +117,12 @@ func (p *editSubcommand) InjectConfig(c config.Config) error { } func (p *editSubcommand) Scaffold(fs machinery.Filesystem) error { + // Handle delete mode + if p.delete { + return p.deleteHelmChart(fs) + } + + // Normal scaffold mode // If using default manifests file, ensure it exists by running make build-installer if p.manifestsFile == DefaultManifestsFile { if err := p.ensureManifestsExist(); err != nil { @@ -414,3 +423,87 @@ func (p *editSubcommand) removeV1AlphaPluginEntry() { slog.Info("removed deprecated v1-alpha plugin entry") } } + +// deleteHelmChart removes Helm chart files and configuration (best effort) +func (p *editSubcommand) deleteHelmChart(fs machinery.Filesystem) error { + slog.Info("Deleting Helm chart files...") + + // Get plugin config to find output directory + key := plugin.GetPluginKeyForConfig(p.config.GetPluginChain(), Plugin{}) + canonicalKey := plugin.KeyFor(Plugin{}) + cfg := pluginConfig{} + + err := p.config.DecodePluginConfig(key, &cfg) + if err != nil { + if errors.As(err, &config.PluginKeyNotFoundError{}) && key != canonicalKey { + _ = p.config.DecodePluginConfig(canonicalKey, &cfg) + } + } + + // Use configured output dir or default + outputDir := p.outputDir + if outputDir == "" { + outputDir = cfg.OutputDir + } + if outputDir == "" { + outputDir = DefaultOutputDir + } + + deletedCount := 0 + warnCount := 0 + + // Delete chart directory (best effort) + chartDir := filepath.Join(outputDir, "chart") + if exists, _ := afero.DirExists(fs.FS, chartDir); exists { + if err := fs.FS.RemoveAll(chartDir); err != nil { + slog.Warn("Failed to delete Helm chart directory", "path", chartDir, "error", err) + warnCount++ + } else { + slog.Info("Deleted Helm chart directory", "path", chartDir) + deletedCount++ + } + } else { + slog.Warn("Helm chart directory not found", "path", chartDir) + warnCount++ + } + + // Delete test workflow (best effort) + testChartPath := filepath.Join(".github", "workflows", "test-chart.yml") + if exists, _ := afero.Exists(fs.FS, testChartPath); exists { + if err := fs.FS.Remove(testChartPath); err != nil { + slog.Warn("Failed to delete test-chart.yml", "path", testChartPath, "error", err) + warnCount++ + } else { + slog.Info("Deleted test-chart workflow", "path", testChartPath) + deletedCount++ + } + } else { + slog.Warn("Test chart workflow not found", "path", testChartPath) + warnCount++ + } + + // Remove plugin config from PROJECT by encoding empty struct + if encErr := p.config.EncodePluginConfig(key, struct{}{}); encErr != nil { + // Try canonical key if different + if key != canonicalKey { + if encErr2 := p.config.EncodePluginConfig(canonicalKey, struct{}{}); encErr2 != nil { + slog.Warn("Failed to remove plugin configuration from PROJECT file", + "provided_key_error", encErr, "canonical_key_error", encErr2) + warnCount++ + } + } else { + slog.Warn("Failed to remove plugin configuration from PROJECT file", "error", encErr) + warnCount++ + } + } + + fmt.Printf("\nSuccessfully completed Helm plugin deletion\n") + if deletedCount > 0 { + fmt.Printf("Deleted: %d item(s)\n", deletedCount) + } + if warnCount > 0 { + fmt.Printf("Warnings: %d item(s) - some files may not exist or couldn't be deleted (see logs)\n", warnCount) + } + + return nil +} diff --git a/pkg/plugins/optional/helm/v2alpha/plugin.go b/pkg/plugins/optional/helm/v2alpha/plugin.go index afa83e45b26..cd7fcef72e5 100644 --- a/pkg/plugins/optional/helm/v2alpha/plugin.go +++ b/pkg/plugins/optional/helm/v2alpha/plugin.go @@ -36,7 +36,10 @@ type Plugin struct { editSubcommand } -var _ plugin.Edit = Plugin{} +var ( + _ plugin.Edit = Plugin{} + _ plugin.HasDeleteSupport = Plugin{} +) // PluginConfig defines the structure that will be used to track the data type pluginConfig struct { @@ -56,6 +59,9 @@ func (Plugin) SupportedProjectVersions() []config.Version { return supportedProj // GetEditSubcommand will return the subcommand which is responsible for adding and/or edit a helm chart func (p Plugin) GetEditSubcommand() plugin.EditSubcommand { return &p.editSubcommand } +// SupportsDelete returns true indicating this plugin supports deletion via Edit --delete +func (Plugin) SupportsDelete() bool { return true } + // Description returns a short description of the plugin func (Plugin) Description() string { return "Generates a Helm chart for project distribution" diff --git a/test/e2e/utils/test_context.go b/test/e2e/utils/test_context.go index ef1605e7ab1..bf15cd85207 100644 --- a/test/e2e/utils/test_context.go +++ b/test/e2e/utils/test_context.go @@ -242,6 +242,39 @@ func (t *TestContext) CreateWebhook(resourceOptions ...string) error { return err } +// DeleteAPI is for running `kubebuilder delete api` +func (t *TestContext) DeleteAPI(resourceOptions ...string) error { + resourceOptions = append([]string{"delete", "api"}, resourceOptions...) + //nolint:gosec + cmd := exec.Command(t.BinaryName, resourceOptions...) + _, err := t.Run(cmd) + return err +} + +// DeleteWebhook is for running `kubebuilder delete webhook` +func (t *TestContext) DeleteWebhook(resourceOptions ...string) error { + resourceOptions = append([]string{"delete", "webhook"}, resourceOptions...) + //nolint:gosec + cmd := exec.Command(t.BinaryName, resourceOptions...) + _, err := t.Run(cmd) + return err +} + +// Delete is for running `kubebuilder delete` (for optional plugins) +func (t *TestContext) Delete(resourceOptions ...string) error { + resourceOptions = append([]string{"delete"}, resourceOptions...) + //nolint:gosec + cmd := exec.Command(t.BinaryName, resourceOptions...) + _, err := t.Run(cmd) + return err +} + +// HasFile checks if a file exists in the test project directory +func (t *TestContext) HasFile(path string) bool { + _, err := os.Stat(filepath.Join(t.Dir, path)) + return err == nil +} + // Regenerate is for running `kubebuilder alpha generate` func (t *TestContext) Regenerate(resourceOptions ...string) error { resourceOptions = append([]string{"alpha", "generate"}, resourceOptions...)