diff --git a/docs/book/src/plugins/extending/external-plugins.md b/docs/book/src/plugins/extending/external-plugins.md index a957f88fcc8..6d06f28a1d9 100644 --- a/docs/book/src/plugins/extending/external-plugins.md +++ b/docs/book/src/plugins/extending/external-plugins.md @@ -41,6 +41,28 @@ structures. } ``` +**Note:** When executing commands other than `init` (e.g., `create api`, `create webhook`, `edit`), the `PluginRequest` will also include a `config` field containing the PROJECT file configuration: + +```json +{ + "apiVersion": "v1alpha1", + "args": ["--group", "crew", "--version", "v1", "--kind", "Captain"], + "command": "create api", + "universe": {}, + "config": { + "domain": "my.domain", + "repo": "github.com/example/my-project", + "projectName": "my-project", + "version": "3", + "layout": ["go.kubebuilder.io/v4"], + "multigroup": false, + "resources": [] + } +} +``` + +The `config` field provides external plugins access to the PROJECT file configuration, enabling them to make informed decisions based on the project's settings. This field will be omitted from the JSON entirely (not present) during the `init` command if the PROJECT file has not been created yet. External plugins should check for the presence of this field before attempting to use it. + ### PluginResponse `PluginResponse` contains the modifications made by the plugin to the project. This data is serialized as JSON and returned to Kubebuilder through `stdout`. diff --git a/pkg/plugin/external/types.go b/pkg/plugin/external/types.go index 914ab054d6f..854f110baa4 100644 --- a/pkg/plugin/external/types.go +++ b/pkg/plugin/external/types.go @@ -35,6 +35,10 @@ type PluginRequest struct { // Universe represents the modified file contents that gets updated over a series of plugin runs // across the plugin chain. Initially, it starts out as empty. Universe map[string]string `json:"universe"` + + // Config contains the PROJECT file config. This field may be empty if the + // project is being initialized and the PROJECT file has not been created yet. + Config map[string]interface{} `json:"config,omitempty"` } // PluginResponse is returned to kubebuilder by the plugin and contains all files diff --git a/pkg/plugins/external/api.go b/pkg/plugins/external/api.go index 4a06c8044eb..c140ea78b6a 100644 --- a/pkg/plugins/external/api.go +++ b/pkg/plugins/external/api.go @@ -19,6 +19,7 @@ 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" @@ -32,6 +33,8 @@ const ( ) type createAPISubcommand struct { + config config.Config + Path string Args []string } @@ -49,6 +52,11 @@ func (p *createAPISubcommand) BindFlags(fs *pflag.FlagSet) { bindExternalPluginFlags(fs, "api", p.Path, p.Args) } +func (p *createAPISubcommand) InjectConfig(c config.Config) error { + p.config = c + return nil +} + func (p *createAPISubcommand) Scaffold(fs machinery.Filesystem) error { req := external.PluginRequest{ APIVersion: defaultAPIVersion, @@ -56,7 +64,7 @@ func (p *createAPISubcommand) Scaffold(fs machinery.Filesystem) error { Args: p.Args, } - err := handlePluginResponse(fs, req, p.Path) + err := handlePluginResponse(fs, req, p.Path, p.config) if err != nil { return err } diff --git a/pkg/plugins/external/edit.go b/pkg/plugins/external/edit.go index 0cb7a9ee5be..5db108207d9 100644 --- a/pkg/plugins/external/edit.go +++ b/pkg/plugins/external/edit.go @@ -19,6 +19,7 @@ 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/plugin" "sigs.k8s.io/kubebuilder/v4/pkg/plugin/external" @@ -27,6 +28,8 @@ import ( var _ plugin.EditSubcommand = &editSubcommand{} type editSubcommand struct { + config config.Config + Path string Args []string } @@ -39,6 +42,13 @@ func (p *editSubcommand) BindFlags(fs *pflag.FlagSet) { bindExternalPluginFlags(fs, "edit", p.Path, p.Args) } +//nolint:dupl +func (p *editSubcommand) InjectConfig(c config.Config) error { + p.config = c + return nil +} + +//nolint:dupl func (p *editSubcommand) Scaffold(fs machinery.Filesystem) error { req := external.PluginRequest{ APIVersion: defaultAPIVersion, @@ -46,7 +56,7 @@ func (p *editSubcommand) Scaffold(fs machinery.Filesystem) error { Args: p.Args, } - err := handlePluginResponse(fs, req, p.Path) + err := handlePluginResponse(fs, req, p.Path, p.config) if err != nil { return err } diff --git a/pkg/plugins/external/external_test.go b/pkg/plugins/external/external_test.go index 2b8e0cbdb47..21cd0ae070a 100644 --- a/pkg/plugins/external/external_test.go +++ b/pkg/plugins/external/external_test.go @@ -28,6 +28,7 @@ import ( "github.com/spf13/afero" "github.com/spf13/pflag" + v3 "sigs.k8s.io/kubebuilder/v4/pkg/config/v3" "sigs.k8s.io/kubebuilder/v4/pkg/machinery" "sigs.k8s.io/kubebuilder/v4/pkg/plugin" "sigs.k8s.io/kubebuilder/v4/pkg/plugin/external" @@ -75,6 +76,28 @@ func (m *mockInValidOsWdGetter) GetCurrentDir() (string, error) { return "", fmt.Errorf("error getting current directory") } +// mockConfigOutputGetter captures the request to verify config is passed +type mockConfigOutputGetter struct { + capturedRequest *external.PluginRequest +} + +var _ ExecOutputGetter = &mockConfigOutputGetter{} + +func (m *mockConfigOutputGetter) GetExecOutput(reqBytes []byte, _ string) ([]byte, error) { + // Capture the request for verification + m.capturedRequest = &external.PluginRequest{} + if err := json.Unmarshal(reqBytes, m.capturedRequest); err != nil { + return nil, fmt.Errorf("error unmarshalling request: %w", err) + } + + return []byte(`{ + "command": "init", + "error": false, + "error_msg": "none", + "universe": {"LICENSE": "Apache 2.0 License\n"} + }`), nil +} + type mockValidFlagOutputGetter struct{} func (m *mockValidFlagOutputGetter) GetExecOutput(_ []byte, _ string) ([]byte, error) { @@ -754,6 +777,133 @@ var _ = Describe("Run external plugin using Scaffold", func() { } }) }) + + Context("with config injection", func() { + const filePerm os.FileMode = 755 + var ( + pluginFileName string + args []string + f afero.File + fs machinery.Filesystem + mockGetter *mockConfigOutputGetter + cfg *v3.Cfg + + err error + ) + + BeforeEach(func() { + mockGetter = &mockConfigOutputGetter{} + outputGetter = mockGetter + currentDirGetter = &mockValidOsWdGetter{} + fs = machinery.Filesystem{ + FS: afero.NewMemMapFs(), + } + + pluginFileName = "externalPlugin.sh" + pluginFilePath := filepath.Join("tmp", "externalPlugin", pluginFileName) + + err = fs.FS.MkdirAll(filepath.Dir(pluginFilePath), filePerm) + Expect(err).ToNot(HaveOccurred()) + + f, err = fs.FS.Create(pluginFilePath) + Expect(err).ToNot(HaveOccurred()) + Expect(f).ToNot(BeNil()) + + _, err = fs.FS.Stat(pluginFilePath) + Expect(err).ToNot(HaveOccurred()) + + args = []string{"--domain", "example.com"} + + // Create a config instance + cfg = &v3.Cfg{ + Version: v3.Version, + Domain: "test.domain", + Repository: "github.com/test/repo", + Name: "test-project", + } + }) + + It("should pass config to external plugin on init subcommand", func() { + i := initSubcommand{ + Path: pluginFileName, + Args: args, + config: cfg, + } + + err = i.Scaffold(fs) + Expect(err).ToNot(HaveOccurred()) + + // Verify that config was captured in the request + Expect(mockGetter.capturedRequest).ToNot(BeNil()) + Expect(mockGetter.capturedRequest.Config).ToNot(BeNil()) + Expect(mockGetter.capturedRequest.Config["domain"]).To(Equal("test.domain")) + Expect(mockGetter.capturedRequest.Config["repo"]).To(Equal("github.com/test/repo")) + Expect(mockGetter.capturedRequest.Config["projectName"]).To(Equal("test-project")) + }) + + It("should pass config to external plugin on create api subcommand", func() { + c := createAPISubcommand{ + Path: pluginFileName, + Args: args, + config: cfg, + } + + err = c.Scaffold(fs) + Expect(err).ToNot(HaveOccurred()) + + // Verify that config was captured in the request + Expect(mockGetter.capturedRequest).ToNot(BeNil()) + Expect(mockGetter.capturedRequest.Config).ToNot(BeNil()) + Expect(mockGetter.capturedRequest.Config["domain"]).To(Equal("test.domain")) + }) + + It("should pass config to external plugin on create webhook subcommand", func() { + c := createWebhookSubcommand{ + Path: pluginFileName, + Args: args, + config: cfg, + } + + err = c.Scaffold(fs) + Expect(err).ToNot(HaveOccurred()) + + // Verify that config was captured in the request + Expect(mockGetter.capturedRequest).ToNot(BeNil()) + Expect(mockGetter.capturedRequest.Config).ToNot(BeNil()) + Expect(mockGetter.capturedRequest.Config["domain"]).To(Equal("test.domain")) + }) + + It("should pass config to external plugin on edit subcommand", func() { + e := editSubcommand{ + Path: pluginFileName, + Args: args, + config: cfg, + } + + err = e.Scaffold(fs) + Expect(err).ToNot(HaveOccurred()) + + // Verify that config was captured in the request + Expect(mockGetter.capturedRequest).ToNot(BeNil()) + Expect(mockGetter.capturedRequest.Config).ToNot(BeNil()) + Expect(mockGetter.capturedRequest.Config["domain"]).To(Equal("test.domain")) + }) + + It("should handle nil config gracefully", func() { + i := initSubcommand{ + Path: pluginFileName, + Args: args, + config: nil, + } + + err = i.Scaffold(fs) + Expect(err).ToNot(HaveOccurred()) + + // Verify that request was made but config is nil + Expect(mockGetter.capturedRequest).ToNot(BeNil()) + Expect(mockGetter.capturedRequest.Config).To(BeNil()) + }) + }) }) func getFlags() []external.Flag { diff --git a/pkg/plugins/external/helpers.go b/pkg/plugins/external/helpers.go index 7f59541a75a..d6fad7c3792 100644 --- a/pkg/plugins/external/helpers.go +++ b/pkg/plugins/external/helpers.go @@ -30,7 +30,9 @@ import ( "github.com/spf13/afero" "github.com/spf13/pflag" + "sigs.k8s.io/yaml" + "sigs.k8s.io/kubebuilder/v4/pkg/config" "sigs.k8s.io/kubebuilder/v4/pkg/machinery" "sigs.k8s.io/kubebuilder/v4/pkg/plugin" "sigs.k8s.io/kubebuilder/v4/pkg/plugin/external" @@ -149,7 +151,7 @@ func getUniverseMap(fs machinery.Filesystem) (map[string]string, error) { return universe, nil } -func handlePluginResponse(fs machinery.Filesystem, req external.PluginRequest, path string) error { +func handlePluginResponse(fs machinery.Filesystem, req external.PluginRequest, path string, cfg config.Config) error { var err error req.Universe, err = getUniverseMap(fs) @@ -157,6 +159,22 @@ func handlePluginResponse(fs machinery.Filesystem, req external.PluginRequest, p return fmt.Errorf("error getting universe map: %w", err) } + // Marshal config to include in the request if config is provided + if cfg != nil { + var configData []byte + configData, err = cfg.MarshalYAML() + if err != nil { + return fmt.Errorf("error marshaling config: %w", err) + } + + var configMap map[string]interface{} + if err = yaml.Unmarshal(configData, &configMap); err != nil { + return fmt.Errorf("error unmarshaling config to map: %w", err) + } + + req.Config = configMap + } + res, err := makePluginRequest(req, path) if err != nil { return fmt.Errorf("error making request to external plugin: %w", err) diff --git a/pkg/plugins/external/init.go b/pkg/plugins/external/init.go index 176b9b044e6..4885d5380b6 100644 --- a/pkg/plugins/external/init.go +++ b/pkg/plugins/external/init.go @@ -19,6 +19,7 @@ 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/plugin" "sigs.k8s.io/kubebuilder/v4/pkg/plugin/external" @@ -27,6 +28,8 @@ import ( var _ plugin.InitSubcommand = &initSubcommand{} type initSubcommand struct { + config config.Config + Path string Args []string } @@ -39,6 +42,13 @@ func (p *initSubcommand) BindFlags(fs *pflag.FlagSet) { bindExternalPluginFlags(fs, "init", p.Path, p.Args) } +//nolint:dupl +func (p *initSubcommand) InjectConfig(c config.Config) error { + p.config = c + return nil +} + +//nolint:dupl func (p *initSubcommand) Scaffold(fs machinery.Filesystem) error { req := external.PluginRequest{ APIVersion: defaultAPIVersion, @@ -46,7 +56,7 @@ func (p *initSubcommand) Scaffold(fs machinery.Filesystem) error { Args: p.Args, } - err := handlePluginResponse(fs, req, p.Path) + err := handlePluginResponse(fs, req, p.Path, p.config) if err != nil { return err } diff --git a/pkg/plugins/external/webhook.go b/pkg/plugins/external/webhook.go index 259d42822ee..5e1d96bcd8d 100644 --- a/pkg/plugins/external/webhook.go +++ b/pkg/plugins/external/webhook.go @@ -19,6 +19,7 @@ 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" @@ -28,6 +29,8 @@ import ( var _ plugin.CreateWebhookSubcommand = &createWebhookSubcommand{} type createWebhookSubcommand struct { + config config.Config + Path string Args []string } @@ -45,6 +48,11 @@ func (p *createWebhookSubcommand) BindFlags(fs *pflag.FlagSet) { bindExternalPluginFlags(fs, "webhook", p.Path, p.Args) } +func (p *createWebhookSubcommand) InjectConfig(c config.Config) error { + p.config = c + return nil +} + func (p *createWebhookSubcommand) Scaffold(fs machinery.Filesystem) error { req := external.PluginRequest{ APIVersion: defaultAPIVersion, @@ -52,7 +60,7 @@ func (p *createWebhookSubcommand) Scaffold(fs machinery.Filesystem) error { Args: p.Args, } - err := handlePluginResponse(fs, req, p.Path) + err := handlePluginResponse(fs, req, p.Path, p.config) if err != nil { return err }