diff --git a/docs/book/src/plugins/extending/multicluster-runtime-plugin.md b/docs/book/src/plugins/extending/multicluster-runtime-plugin.md new file mode 100644 index 00000000000..c08a005a5bd --- /dev/null +++ b/docs/book/src/plugins/extending/multicluster-runtime-plugin.md @@ -0,0 +1,165 @@ +# multicluster-runtime/v1-alpha Plugin + +The `multicluster-runtime/v1-alpha` plugin adds +[sigs.k8s.io/multicluster-runtime](https://github.com/kubernetes-sigs/multicluster-runtime) +support to a Kubebuilder project. Instead of reconciling objects in a single cluster, your +controller will reconcile objects across all clusters registered with the chosen provider. + +This plugin is built into the `kubebuilder` binary — no separate installation is required. + +## When to use this plugin + +Use `multicluster-runtime/v1-alpha` when you need to: + +- Manage resources across **multiple Kubernetes clusters** from a single operator binary +- React to cluster lifecycle events (clusters joining or leaving the fleet) +- Run **fleet-wide controllers** while still using familiar controller-runtime patterns + +## Prerequisites + +- Kubebuilder v4+ +- Go 1.22+ + +## Project initialization + +Chain `multicluster-runtime/v1-alpha` after `go/v4`: + +```bash +kubebuilder init \ + --plugins go/v4,multicluster-runtime/v1-alpha \ + --domain example.com \ + --repo github.com/example/myop \ + --provider kubeconfig +``` + +The plugin rewrites `cmd/main.go` to use `mcmanager.New(...)` instead of `ctrl.NewManager(...)`. + +## Provider selection guide + +The `--provider` flag (default: `kubeconfig`) controls how clusters are discovered. + +| Provider | Flag value | Use case | +|---|---|---| +| **Kubeconfig secrets** | `kubeconfig` | Dynamic fleet — clusters join/leave at runtime by creating kubeconfig Secrets | +| **Namespace** | `namespace` | Single cluster, namespace-per-tenant; each namespace is treated as a "cluster" | +| **Cluster API** | `cluster-api` | Fleet managed by [Cluster API](https://cluster-api.sigs.k8s.io/) controllers | +| **File** | `file` | Static cluster list; one kubeconfig file per cluster in a directory (great for CI) | + +### Kubeconfig provider (default) + +```bash +kubebuilder init --plugins go/v4,multicluster-runtime/v1-alpha \ + --domain example.com --repo github.com/example/myop \ + --provider kubeconfig +``` + +Add a cluster at runtime by creating a Secret with the kubeconfig: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: my-cluster + namespace: default + labels: + # label recognized by the kubeconfig provider + multicluster.x-k8s.io/cluster-name: my-cluster +type: Opaque +data: + kubeconfig: +``` + +### Namespace provider + +```bash +kubebuilder init --plugins go/v4,multicluster-runtime/v1-alpha \ + --domain example.com --repo github.com/example/myop \ + --provider namespace +``` + +The generated `cmd/main.go` starts the provider and manager concurrently using `errgroup`. + +### File provider + +```bash +kubebuilder init --plugins go/v4,multicluster-runtime/v1-alpha \ + --domain example.com --repo github.com/example/myop \ + --provider file \ + --kubeconfig-dir /etc/kubeconfig +``` + +Place one kubeconfig file per cluster in `--kubeconfig-dir`. Each file name becomes the +cluster name. + +## Create a multicluster controller + +```bash +kubebuilder create api \ + --plugins go/v4,multicluster-runtime/v1-alpha \ + --group foo --version v1 --kind Foo \ + --controller --resource +``` + +The generated controller (`internal/controller/foo_controller.go`) uses: + +- `mcreconcile.Request` — carries `ClusterName` in addition to the usual `NamespacedName` +- `mcbuilder.ControllerManagedBy(mgr)` — watches objects across all registered clusters +- `mcmanager.Manager` — the multicluster-aware manager type + +### Using `req.ClusterName` + +```go +func (r *FooReconciler) Reconcile(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) { + log := log.FromContext(ctx).WithValues("cluster", req.ClusterName) + + // Fetch the object from the correct cluster's cache. + foo := &foov1.Foo{} + if err := r.Get(ctx, req.NamespacedName, foo); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + log.Info("Reconciling Foo", "name", foo.Name) + // ... your business logic ... + return ctrl.Result{}, nil +} +``` + +## Webhooks + +Webhooks register with the **local cluster's** API server — they do not need multicluster +changes. You can scaffold them normally: + +```bash +kubebuilder create webhook \ + --plugins go/v4,multicluster-runtime/v1-alpha \ + --group foo --version v1 --kind Foo \ + --defaulting --programmatic-validation +``` + +The `multicluster-runtime/v1-alpha` plugin does not modify webhook files. The webhook +scaffolding is handled entirely by `go/v4` and the output is identical to a single-cluster +project. Webhooks register with the **local** cluster's API server and do not need +multicluster changes. + +## Switching providers + +Use `kubebuilder edit` to replace the provider in an existing project: + +```bash +kubebuilder edit --plugins multicluster-runtime/v1-alpha --provider namespace +``` + +This rewrites `cmd/main.go` while preserving all `// +kubebuilder:scaffold:*` markers so +that future `kubebuilder create api` and `kubebuilder create webhook` commands still work. + +## Plugin chain note + +This plugin is designed to run **after** `go/v4`. The plugin chain `go/v4,multicluster-runtime/v1-alpha` +means: + +1. `go/v4` scaffolds the standard project structure +2. `multicluster-runtime/v1-alpha` rewrites `cmd/main.go` to use the multicluster manager + +If `go/v4` is absent from the chain, the scaffolded `cmd/main.go` will not compile +because the standard project structure (`api/`, `internal/controller/`, `Makefile`, etc.) +will be missing. Always chain `go/v4` first. diff --git a/internal/cli/cmd/cmd.go b/internal/cli/cmd/cmd.go index c48d6533885..9889d2d5890 100644 --- a/internal/cli/cmd/cmd.go +++ b/internal/cli/cmd/cmd.go @@ -36,6 +36,7 @@ import ( grafanav1alpha "sigs.k8s.io/kubebuilder/v4/pkg/plugins/optional/grafana/v1alpha" helmv1alpha "sigs.k8s.io/kubebuilder/v4/pkg/plugins/optional/helm/v1alpha" //nolint:staticcheck // Deprecated helmv2alpha "sigs.k8s.io/kubebuilder/v4/pkg/plugins/optional/helm/v2alpha" + multiclusterruntimev1alpha "sigs.k8s.io/kubebuilder/v4/pkg/plugins/optional/multicluster-runtime/v1alpha" ) // Run bootstraps & runs the CLI @@ -78,6 +79,7 @@ func Run() { &helmv1alpha.Plugin{}, &helmv2alpha.Plugin{}, &autoupdatev1alpha.Plugin{}, + &multiclusterruntimev1alpha.Plugin{}, ), cli.WithPlugins(externalPlugins...), cli.WithDefaultPlugins(cfgv3.Version, gov4Bundle), diff --git a/pkg/plugins/optional/multicluster-runtime/v1alpha/api.go b/pkg/plugins/optional/multicluster-runtime/v1alpha/api.go new file mode 100644 index 00000000000..169f8c7ec91 --- /dev/null +++ b/pkg/plugins/optional/multicluster-runtime/v1alpha/api.go @@ -0,0 +1,68 @@ +/* +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 ( + "fmt" + + "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/plugins/optional/multicluster-runtime/v1alpha/scaffolds" +) + +var _ plugin.CreateAPISubcommand = &createAPISubcommand{} + +type createAPISubcommand struct { + config config.Config + resource *resource.Resource +} + +func (p *createAPISubcommand) UpdateMetadata(cliMeta plugin.CLIMetadata, subcmdMeta *plugin.SubcommandMetadata) { + subcmdMeta.Description = `Overwrite the controller scaffolded by go/v4 with a multicluster-aware version. + +The generated controller uses: + - mcreconcile.Request (carries ClusterName alongside NamespacedName) + - mcbuilder.ControllerManagedBy(mgr) (watches across all registered clusters) + - mcmanager.Manager (multicluster manager type)` + subcmdMeta.Examples = fmt.Sprintf(` %[1]s create api \ + --plugins go/v4,%[2]s \ + --group foo --version v1 --kind Foo --controller --resource`, cliMeta.CommandName, plugin.KeyFor(Plugin{})) +} + +func (p *createAPISubcommand) InjectConfig(c config.Config) error { + p.config = c + return nil +} + +func (p *createAPISubcommand) InjectResource(res *resource.Resource) error { + p.resource = res + return nil +} + +func (p *createAPISubcommand) Scaffold(fs machinery.Filesystem) error { + if p.resource == nil || !p.resource.HasController() { + return nil + } + s := scaffolds.NewAPIScaffolder(p.config, *p.resource) + s.InjectFS(fs) + if err := s.Scaffold(); err != nil { + return fmt.Errorf("failed to scaffold api: %w", err) + } + return nil +} diff --git a/pkg/plugins/optional/multicluster-runtime/v1alpha/api_test.go b/pkg/plugins/optional/multicluster-runtime/v1alpha/api_test.go new file mode 100644 index 00000000000..d51ea0ffc51 --- /dev/null +++ b/pkg/plugins/optional/multicluster-runtime/v1alpha/api_test.go @@ -0,0 +1,103 @@ +/* +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 ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/afero" + + "sigs.k8s.io/kubebuilder/v4/pkg/config" + cfgv3 "sigs.k8s.io/kubebuilder/v4/pkg/config/v3" + "sigs.k8s.io/kubebuilder/v4/pkg/machinery" + "sigs.k8s.io/kubebuilder/v4/pkg/model/resource" +) + +const controllerPath = "internal/controller/bar_controller.go" + +var _ = Describe("createAPISubcommand", func() { + var ( + subCmd *createAPISubcommand + cfg config.Config + ) + + BeforeEach(func() { + subCmd = &createAPISubcommand{} + cfg = cfgv3.New() + _ = cfg.SetRepository("github.com/example/myop") + _ = cfg.SetDomain("example.com") + Expect(subCmd.InjectConfig(cfg)).To(Succeed()) + }) + + Context("InjectConfig", func() { + It("should store the config", func() { + Expect(subCmd.config).To(Equal(cfg)) + }) + }) + + Context("InjectResource", func() { + It("should store the resource", func() { + res := &resource.Resource{ + GVK: resource.GVK{Group: "foo", Version: "v1", Kind: "Bar"}, + } + Expect(subCmd.InjectResource(res)).To(Succeed()) + Expect(subCmd.resource).To(Equal(res)) + }) + }) + + Context("Scaffold", func() { + var memFS machinery.Filesystem + + BeforeEach(func() { + memFS = machinery.Filesystem{FS: afero.NewMemMapFs()} + }) + + It("should be a no-op when resource is nil", func() { + subCmd.resource = nil + Expect(subCmd.Scaffold(memFS)).To(Succeed()) + }) + + It("should be a no-op when resource has no controller", func() { + subCmd.resource = &resource.Resource{ + GVK: resource.GVK{Group: "foo", Version: "v1", Kind: "Bar"}, + Controller: false, + } + Expect(subCmd.Scaffold(memFS)).To(Succeed()) + }) + + It("should write a controller when resource has a controller", func() { + subCmd.resource = &resource.Resource{ + GVK: resource.GVK{Group: "foo", Version: "v1", Kind: "Bar"}, + Plural: "bars", + Path: "github.com/example/myop/api/v1", + Controller: true, + } + Expect(subCmd.Scaffold(memFS)).To(Succeed()) + + exists, err := afero.Exists(memFS.FS, controllerPath) + Expect(err).NotTo(HaveOccurred()) + Expect(exists).To(BeTrue()) + + content, err := afero.ReadFile(memFS.FS, controllerPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("mcreconcile.Request")) + Expect(string(content)).To(ContainSubstring("mcbuilder.ControllerManagedBy")) + Expect(string(content)).To(ContainSubstring("mcmanager.Manager")) + Expect(string(content)).To(ContainSubstring(`"sigs.k8s.io/multicluster-runtime/pkg/reconcile"`)) + }) + }) +}) diff --git a/pkg/plugins/optional/multicluster-runtime/v1alpha/edit.go b/pkg/plugins/optional/multicluster-runtime/v1alpha/edit.go new file mode 100644 index 00000000000..c0b172fb3ae --- /dev/null +++ b/pkg/plugins/optional/multicluster-runtime/v1alpha/edit.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. +*/ + +package v1alpha + +import ( + "fmt" + "strings" + + "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" + pluginutil "sigs.k8s.io/kubebuilder/v4/pkg/plugin/util" + "sigs.k8s.io/kubebuilder/v4/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds" +) + +var _ plugin.EditSubcommand = &editSubcommand{} + +type editSubcommand struct { + config config.Config + provider string + kubeconfigDir string +} + +func (p *editSubcommand) UpdateMetadata(cliMeta plugin.CLIMetadata, subcmdMeta *plugin.SubcommandMetadata) { + subcmdMeta.Description = `Switch the multicluster provider used in cmd/main.go. + +Rewrites cmd/main.go while preserving all +kubebuilder:scaffold markers so that +future kubebuilder create api and create webhook commands still work.` + subcmdMeta.Examples = fmt.Sprintf(` # Switch to namespace provider + %[1]s edit --plugins %[2]s --provider namespace`, cliMeta.CommandName, plugin.KeyFor(Plugin{})) +} + +func (p *editSubcommand) BindFlags(fs *pflag.FlagSet) { + fs.StringVar(&p.provider, "provider", "kubeconfig", + fmt.Sprintf("Switch the multicluster provider (%s)", strings.Join(validProviders, "|"))) + fs.StringVar(&p.kubeconfigDir, "kubeconfig-dir", "/etc/kubeconfig", + "Directory of per-cluster kubeconfig files (file provider only)") +} + +func (p *editSubcommand) InjectConfig(c config.Config) error { + p.config = c + return nil +} + +func (p *editSubcommand) Scaffold(fs machinery.Filesystem) error { + if err := validateProvider(p.provider); err != nil { + return err + } + s := scaffolds.NewEditScaffolder(p.config, p.provider, p.kubeconfigDir) + s.InjectFS(fs) + if err := s.Scaffold(); err != nil { + return fmt.Errorf("failed to scaffold edit: %w", err) + } + return nil +} + +// PostScaffold ensures sigs.k8s.io/multicluster-runtime is present and tidies go.mod. +func (p *editSubcommand) PostScaffold() error { + if err := pluginutil.RunCmd("Get multicluster-runtime", "go", "get", + "sigs.k8s.io/multicluster-runtime@"+scaffolds.MulticlusterRuntimeVersion); err != nil { + return fmt.Errorf("error getting multicluster-runtime: %w", err) + } + if err := pluginutil.RunCmd("Update dependencies", "go", "mod", "tidy"); err != nil { + return fmt.Errorf("error updating go dependencies: %w", err) + } + return nil +} diff --git a/pkg/plugins/optional/multicluster-runtime/v1alpha/edit_test.go b/pkg/plugins/optional/multicluster-runtime/v1alpha/edit_test.go new file mode 100644 index 00000000000..4f7f74a8663 --- /dev/null +++ b/pkg/plugins/optional/multicluster-runtime/v1alpha/edit_test.go @@ -0,0 +1,106 @@ +/* +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 ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/afero" + "github.com/spf13/pflag" + + "sigs.k8s.io/kubebuilder/v4/pkg/config" + cfgv3 "sigs.k8s.io/kubebuilder/v4/pkg/config/v3" + "sigs.k8s.io/kubebuilder/v4/pkg/machinery" +) + +var _ = Describe("editSubcommand", func() { + var ( + subCmd *editSubcommand + cfg config.Config + fs *pflag.FlagSet + ) + + BeforeEach(func() { + subCmd = &editSubcommand{} + cfg = cfgv3.New() + _ = cfg.SetRepository("github.com/example/myop") + _ = cfg.SetDomain("example.com") + fs = pflag.NewFlagSet("test", pflag.ContinueOnError) + subCmd.BindFlags(fs) + }) + + Context("InjectConfig", func() { + It("should store the config", func() { + Expect(subCmd.InjectConfig(cfg)).To(Succeed()) + Expect(subCmd.config).To(Equal(cfg)) + }) + }) + + Context("BindFlags defaults", func() { + It("should default provider to kubeconfig", func() { + Expect(subCmd.provider).To(Equal("kubeconfig")) + }) + + It("should default kubeconfig-dir to /etc/kubeconfig", func() { + Expect(subCmd.kubeconfigDir).To(Equal("/etc/kubeconfig")) + }) + }) + + Context("Scaffold with valid providers", func() { + var memFS machinery.Filesystem + + BeforeEach(func() { + Expect(subCmd.InjectConfig(cfg)).To(Succeed()) + memFS = machinery.Filesystem{FS: afero.NewMemMapFs()} + }) + + for _, provider := range []string{"kubeconfig", "namespace", "cluster-api", "file"} { + It("should succeed with provider "+provider, func() { + subCmd.provider = provider + Expect(subCmd.Scaffold(memFS)).To(Succeed()) + }) + } + }) + + Context("Scaffold with invalid provider", func() { + It("should return an error for unknown provider", func() { + Expect(subCmd.InjectConfig(cfg)).To(Succeed()) + subCmd.provider = "invalid-provider" + err := subCmd.Scaffold(machinery.Filesystem{FS: afero.NewMemMapFs()}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unknown provider")) + Expect(err.Error()).To(ContainSubstring("invalid-provider")) + }) + }) + + Context("Scaffold produces cmd/main.go", func() { + var memFS machinery.Filesystem + + BeforeEach(func() { + Expect(subCmd.InjectConfig(cfg)).To(Succeed()) + memFS = machinery.Filesystem{FS: afero.NewMemMapFs()} + }) + + It("should write cmd/main.go", func() { + subCmd.provider = "kubeconfig" + Expect(subCmd.Scaffold(memFS)).To(Succeed()) + exists, err := afero.Exists(memFS.FS, "cmd/main.go") + Expect(err).NotTo(HaveOccurred()) + Expect(exists).To(BeTrue()) + }) + }) +}) diff --git a/pkg/plugins/optional/multicluster-runtime/v1alpha/helpers.go b/pkg/plugins/optional/multicluster-runtime/v1alpha/helpers.go new file mode 100644 index 00000000000..02c8307b55d --- /dev/null +++ b/pkg/plugins/optional/multicluster-runtime/v1alpha/helpers.go @@ -0,0 +1,31 @@ +/* +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 ( + "fmt" + "slices" + "strings" +) + +// validateProvider returns an error when p is not one of the recognised provider names. +func validateProvider(p string) error { + if slices.Contains(validProviders, p) { + return nil + } + return fmt.Errorf("unknown provider %q; valid values: %s", p, strings.Join(validProviders, ", ")) +} diff --git a/pkg/plugins/optional/multicluster-runtime/v1alpha/init.go b/pkg/plugins/optional/multicluster-runtime/v1alpha/init.go new file mode 100644 index 00000000000..a8e6cac7d02 --- /dev/null +++ b/pkg/plugins/optional/multicluster-runtime/v1alpha/init.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 v1alpha + +import ( + "fmt" + "strings" + + "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" + pluginutil "sigs.k8s.io/kubebuilder/v4/pkg/plugin/util" + "sigs.k8s.io/kubebuilder/v4/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds" +) + +var _ plugin.InitSubcommand = &initSubcommand{} + +// validProviders is the set of accepted --provider values. +var validProviders = []string{"kubeconfig", "namespace", "cluster-api", "file"} + +type initSubcommand struct { + config config.Config + provider string + kubeconfigDir string +} + +func (p *initSubcommand) UpdateMetadata(cliMeta plugin.CLIMetadata, subcmdMeta *plugin.SubcommandMetadata) { + subcmdMeta.Description = `Rewrites cmd/main.go to use sigs.k8s.io/multicluster-runtime instead of +the standard single-cluster controller-runtime manager. + +Must be chained after go/v4: + kubebuilder init --plugins go/v4,multicluster-runtime/v1-alpha ... + +The --provider flag selects the cluster-discovery mechanism: + kubeconfig Watch kubeconfig Secrets to register clusters at runtime (default) + namespace Treat each namespace as a separate "cluster" + cluster-api Discover clusters managed by Cluster API + file Load clusters from a directory of kubeconfig files` + subcmdMeta.Examples = fmt.Sprintf(` # Kubeconfig provider (default) + %[1]s init --plugins go/v4,%[2]s \ + --domain example.com --provider kubeconfig + + # Namespace provider + %[1]s init --plugins go/v4,%[2]s \ + --domain example.com --provider namespace`, cliMeta.CommandName, plugin.KeyFor(Plugin{})) +} + +func (p *initSubcommand) BindFlags(fs *pflag.FlagSet) { + fs.StringVar(&p.provider, "provider", "kubeconfig", + fmt.Sprintf("Multicluster provider (%s)", strings.Join(validProviders, "|"))) + fs.StringVar(&p.kubeconfigDir, "kubeconfig-dir", "/etc/kubeconfig", + "Directory of per-cluster kubeconfig files (file provider only)") +} + +func (p *initSubcommand) InjectConfig(c config.Config) error { + p.config = c + return nil +} + +func (p *initSubcommand) Scaffold(fs machinery.Filesystem) error { + if err := validateProvider(p.provider); err != nil { + return err + } + s := scaffolds.NewInitScaffolder(p.config, p.provider, p.kubeconfigDir) + s.InjectFS(fs) + if err := s.Scaffold(); err != nil { + return fmt.Errorf("failed to scaffold init: %w", err) + } + return nil +} + +// PostScaffold pins sigs.k8s.io/multicluster-runtime and tidies go.mod, +// mirroring the pattern used by go/v4. +func (p *initSubcommand) PostScaffold() error { + if err := pluginutil.RunCmd("Get multicluster-runtime", "go", "get", + "sigs.k8s.io/multicluster-runtime@"+scaffolds.MulticlusterRuntimeVersion); err != nil { + return fmt.Errorf("error getting multicluster-runtime: %w", err) + } + if err := pluginutil.RunCmd("Update dependencies", "go", "mod", "tidy"); err != nil { + return fmt.Errorf("error updating go dependencies: %w", err) + } + return nil +} diff --git a/pkg/plugins/optional/multicluster-runtime/v1alpha/init_test.go b/pkg/plugins/optional/multicluster-runtime/v1alpha/init_test.go new file mode 100644 index 00000000000..2c69044fd66 --- /dev/null +++ b/pkg/plugins/optional/multicluster-runtime/v1alpha/init_test.go @@ -0,0 +1,104 @@ +/* +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 ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/afero" + "github.com/spf13/pflag" + + "sigs.k8s.io/kubebuilder/v4/pkg/config" + cfgv3 "sigs.k8s.io/kubebuilder/v4/pkg/config/v3" + "sigs.k8s.io/kubebuilder/v4/pkg/machinery" +) + +var _ = Describe("initSubcommand", func() { + var ( + subCmd *initSubcommand + cfg config.Config + fs *pflag.FlagSet + ) + + BeforeEach(func() { + subCmd = &initSubcommand{} + cfg = cfgv3.New() + _ = cfg.SetRepository("github.com/example/myop") + _ = cfg.SetDomain("example.com") + fs = pflag.NewFlagSet("test", pflag.ContinueOnError) + subCmd.BindFlags(fs) + }) + + Context("InjectConfig", func() { + It("should store the config", func() { + Expect(subCmd.InjectConfig(cfg)).To(Succeed()) + Expect(subCmd.config).To(Equal(cfg)) + }) + }) + + Context("BindFlags defaults", func() { + It("should default provider to kubeconfig", func() { + Expect(subCmd.provider).To(Equal("kubeconfig")) + }) + + It("should default kubeconfig-dir to /etc/kubeconfig", func() { + Expect(subCmd.kubeconfigDir).To(Equal("/etc/kubeconfig")) + }) + }) + + Context("Scaffold with valid providers", func() { + var memFS machinery.Filesystem + + BeforeEach(func() { + Expect(subCmd.InjectConfig(cfg)).To(Succeed()) + memFS = machinery.Filesystem{FS: afero.NewMemMapFs()} + }) + + for _, provider := range []string{"kubeconfig", "namespace", "cluster-api", "file"} { + It("should succeed with provider "+provider, func() { + subCmd.provider = provider + Expect(subCmd.Scaffold(memFS)).To(Succeed()) + }) + } + }) + + Context("Scaffold with invalid provider", func() { + It("should return an error for unknown provider", func() { + Expect(subCmd.InjectConfig(cfg)).To(Succeed()) + subCmd.provider = "bogus" + err := subCmd.Scaffold(machinery.Filesystem{FS: afero.NewMemMapFs()}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unknown provider")) + Expect(err.Error()).To(ContainSubstring("bogus")) + }) + }) +}) + +var _ = Describe("validateProvider", func() { + for _, v := range []string{"kubeconfig", "namespace", "cluster-api", "file"} { + It("should accept "+v, func() { + Expect(validateProvider(v)).To(Succeed()) + }) + } + + It("should reject an unknown provider", func() { + err := validateProvider("unknown") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unknown provider")) + Expect(err.Error()).To(ContainSubstring("kubeconfig, namespace, cluster-api, file")) + }) +}) diff --git a/pkg/plugins/optional/multicluster-runtime/v1alpha/plugin.go b/pkg/plugins/optional/multicluster-runtime/v1alpha/plugin.go new file mode 100644 index 00000000000..11e1a21c05c --- /dev/null +++ b/pkg/plugins/optional/multicluster-runtime/v1alpha/plugin.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. +*/ + +// Package v1alpha implements the multicluster-runtime/v1alpha plugin for Kubebuilder. +// +// This plugin modifies the scaffolded project to use sigs.k8s.io/multicluster-runtime +// instead of the standard single-cluster controller-runtime manager, enabling +// controllers to reconcile objects across multiple Kubernetes clusters. +// +// It is designed to be chained after go/v4: +// +// kubebuilder init --plugins go/v4,multicluster-runtime/v1-alpha ... +package v1alpha + +import ( + "sigs.k8s.io/kubebuilder/v4/pkg/config" + cfgv3 "sigs.k8s.io/kubebuilder/v4/pkg/config/v3" + "sigs.k8s.io/kubebuilder/v4/pkg/model/stage" + "sigs.k8s.io/kubebuilder/v4/pkg/plugin" + "sigs.k8s.io/kubebuilder/v4/pkg/plugins" +) + +// pluginName is the fully qualified plugin name. +const pluginName = "multicluster-runtime." + plugins.DefaultNameQualifier + +var ( + pluginVersion = plugin.Version{Number: 1, Stage: stage.Alpha} + supportedProjectVersions = []config.Version{cfgv3.Version} +) + +var ( + _ plugin.Init = Plugin{} + _ plugin.CreateAPI = Plugin{} + _ plugin.Edit = Plugin{} + _ plugin.Describable = Plugin{} +) + +// Plugin implements plugin.Init, plugin.CreateAPI, and plugin.Edit. +type Plugin struct { + initSubcommand + createAPISubcommand + editSubcommand +} + +// Name returns the plugin's qualified name. +func (Plugin) Name() string { return pluginName } + +// Version returns the plugin version. +func (Plugin) Version() plugin.Version { return pluginVersion } + +// SupportedProjectVersions returns the project config versions supported by this plugin. +func (Plugin) SupportedProjectVersions() []config.Version { return supportedProjectVersions } + +// GetInitSubcommand returns the init subcommand. +func (p Plugin) GetInitSubcommand() plugin.InitSubcommand { return &p.initSubcommand } + +// GetCreateAPISubcommand returns the create api subcommand. +func (p Plugin) GetCreateAPISubcommand() plugin.CreateAPISubcommand { return &p.createAPISubcommand } + +// GetEditSubcommand returns the edit subcommand. +func (p Plugin) GetEditSubcommand() plugin.EditSubcommand { return &p.editSubcommand } + +// Description returns a short description of the plugin. +func (Plugin) Description() string { + return "Rewrites cmd/main.go and controllers to use sigs.k8s.io/multicluster-runtime, " + + "enabling controllers to reconcile objects across multiple Kubernetes clusters" +} + +// DeprecationWarning returns empty — this plugin is not deprecated. +func (Plugin) DeprecationWarning() string { return "" } diff --git a/pkg/plugins/optional/multicluster-runtime/v1alpha/plugin_test.go b/pkg/plugins/optional/multicluster-runtime/v1alpha/plugin_test.go new file mode 100644 index 00000000000..163247d4238 --- /dev/null +++ b/pkg/plugins/optional/multicluster-runtime/v1alpha/plugin_test.go @@ -0,0 +1,52 @@ +/* +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 ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + cfgv3 "sigs.k8s.io/kubebuilder/v4/pkg/config/v3" + "sigs.k8s.io/kubebuilder/v4/pkg/model/stage" +) + +var _ = Describe("Plugin", func() { + var p Plugin + + It("should have the correct plugin name", func() { + Expect(p.Name()).To(Equal("multicluster-runtime.kubebuilder.io")) + }) + + It("should be version 1 alpha", func() { + Expect(p.Version().Number).To(Equal(1)) + Expect(p.Version().Stage).To(Equal(stage.Alpha)) + }) + + It("should support project version v3", func() { + Expect(p.SupportedProjectVersions()).To(ContainElement(cfgv3.Version)) + }) + + It("should not be deprecated", func() { + Expect(p.DeprecationWarning()).To(BeEmpty()) + }) + + It("should return non-nil subcommands", func() { + Expect(p.GetInitSubcommand()).NotTo(BeNil()) + Expect(p.GetCreateAPISubcommand()).NotTo(BeNil()) + Expect(p.GetEditSubcommand()).NotTo(BeNil()) + }) +}) diff --git a/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/api.go b/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/api.go new file mode 100644 index 00000000000..8ceeca1d68e --- /dev/null +++ b/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/api.go @@ -0,0 +1,69 @@ +/* +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" + + "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" + "sigs.k8s.io/kubebuilder/v4/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/internal/templates/cmd" + "sigs.k8s.io/kubebuilder/v4/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/internal/templates/controllers" +) + +var _ plugins.Scaffolder = &apiScaffolder{} + +type apiScaffolder struct { + config config.Config + resource resource.Resource + fs machinery.Filesystem +} + +// NewAPIScaffolder returns a Scaffolder for the create api command. +func NewAPIScaffolder(cfg config.Config, res resource.Resource) plugins.Scaffolder { + return &apiScaffolder{config: cfg, resource: res} +} + +// InjectFS implements plugins.Scaffolder. +func (s *apiScaffolder) InjectFS(fs machinery.Filesystem) { s.fs = fs } + +// Scaffold overwrites the controller and controller test files with multicluster-aware versions. +func (s *apiScaffolder) Scaffold() error { + scaffold := machinery.NewScaffold(s.fs, + machinery.WithConfig(s.config), + machinery.WithResource(&s.resource), + ) + + if err := scaffold.Execute( + &controllers.Controller{Force: true}, + &controllers.ControllerTest{Force: true, DoAPI: s.resource.HasAPI()}, + ); err != nil { + return fmt.Errorf("failed to execute scaffold: %w", err) + } + + if s.resource.HasController() { + if err := scaffold.Execute( + &cmd.MainUpdater{WireController: true}, + ); err != nil { + return fmt.Errorf("failed to update cmd/main.go: %w", err) + } + } + + return nil +} diff --git a/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/edit.go b/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/edit.go new file mode 100644 index 00000000000..33eb7d7bfcf --- /dev/null +++ b/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/edit.go @@ -0,0 +1,65 @@ +/* +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" + + "sigs.k8s.io/kubebuilder/v4/pkg/config" + "sigs.k8s.io/kubebuilder/v4/pkg/machinery" + "sigs.k8s.io/kubebuilder/v4/pkg/plugins" + "sigs.k8s.io/kubebuilder/v4/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/internal/templates/cmd" +) + +var _ plugins.Scaffolder = &editScaffolder{} + +type editScaffolder struct { + config config.Config + provider string + kubeconfigDir string + fs machinery.Filesystem +} + +// NewEditScaffolder returns a Scaffolder for the edit command. +func NewEditScaffolder(cfg config.Config, provider, kubeconfigDir string) plugins.Scaffolder { + return &editScaffolder{ + config: cfg, + provider: provider, + kubeconfigDir: kubeconfigDir, + } +} + +// InjectFS implements plugins.Scaffolder. +func (s *editScaffolder) InjectFS(fs machinery.Filesystem) { s.fs = fs } + +// Scaffold rewrites cmd/main.go to switch to the selected provider. +func (s *editScaffolder) Scaffold() error { + scaffold := machinery.NewScaffold(s.fs, + machinery.WithConfig(s.config), + ) + + if err := scaffold.Execute( + &cmd.Main{ + Provider: s.provider, + KubeconfigDir: s.kubeconfigDir, + MulticlusterRuntimeVersion: MulticlusterRuntimeVersion, + }, + ); err != nil { + return fmt.Errorf("failed to execute scaffold: %w", err) + } + return nil +} diff --git a/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/init.go b/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/init.go new file mode 100644 index 00000000000..ae7872ddc65 --- /dev/null +++ b/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/init.go @@ -0,0 +1,71 @@ +/* +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" + + "sigs.k8s.io/kubebuilder/v4/pkg/config" + "sigs.k8s.io/kubebuilder/v4/pkg/machinery" + "sigs.k8s.io/kubebuilder/v4/pkg/plugins" + "sigs.k8s.io/kubebuilder/v4/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/internal/templates/cmd" +) + +const ( + // MulticlusterRuntimeVersion is the version of sigs.k8s.io/multicluster-runtime + // to be used in the scaffolded project. + MulticlusterRuntimeVersion = "v0.23.3" +) + +var _ plugins.Scaffolder = &initScaffolder{} + +type initScaffolder struct { + config config.Config + provider string + kubeconfigDir string + fs machinery.Filesystem +} + +// NewInitScaffolder returns a Scaffolder for the init command. +func NewInitScaffolder(cfg config.Config, provider, kubeconfigDir string) plugins.Scaffolder { + return &initScaffolder{ + config: cfg, + provider: provider, + kubeconfigDir: kubeconfigDir, + } +} + +// InjectFS implements plugins.Scaffolder. +func (s *initScaffolder) InjectFS(fs machinery.Filesystem) { s.fs = fs } + +// Scaffold writes the multicluster-aware cmd/main.go, overwriting go/v4's version. +func (s *initScaffolder) Scaffold() error { + scaffold := machinery.NewScaffold(s.fs, + machinery.WithConfig(s.config), + ) + + if err := scaffold.Execute( + &cmd.Main{ + Provider: s.provider, + KubeconfigDir: s.kubeconfigDir, + MulticlusterRuntimeVersion: MulticlusterRuntimeVersion, + }, + ); err != nil { + return fmt.Errorf("failed to execute scaffold: %w", err) + } + return nil +} diff --git a/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/internal/templates/cmd/main.go b/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/internal/templates/cmd/main.go new file mode 100644 index 00000000000..1b74f746cc9 --- /dev/null +++ b/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/internal/templates/cmd/main.go @@ -0,0 +1,586 @@ +/* +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 cmd + +import ( + "fmt" + "path/filepath" + + "sigs.k8s.io/kubebuilder/v4/pkg/machinery" +) + +const defaultMainPath = "cmd/main.go" + +var _ machinery.Template = &Main{} + +// Main scaffolds cmd/main.go with multicluster-runtime support. +// It overwrites the file created by go/v4. +type Main struct { + machinery.TemplateMixin + machinery.BoilerplateMixin + machinery.DomainMixin + machinery.RepositoryMixin + machinery.ProjectNameMixin + + // Provider is one of: kubeconfig, namespace, cluster-api, file. + Provider string + + // KubeconfigDir is used only by the file provider. + KubeconfigDir string + + // MulticlusterRuntimeVersion is only used to render a versioned pkg.go.dev link + // as a comment in the generated file — it does not modify go.mod. + // The actual dependency is pinned by PostScaffold via `go get`. + MulticlusterRuntimeVersion string +} + +// SetTemplateDefaults implements machinery.Template. +func (f *Main) SetTemplateDefaults() error { + if f.Path == "" { + f.Path = filepath.Join(defaultMainPath) + } + + f.IfExistsAction = machinery.OverwriteFile + + switch f.Provider { + case "namespace": + f.TemplateBody = mainNamespaceProvider + case "cluster-api": + f.TemplateBody = mainClusterAPIProvider + case "file": + f.TemplateBody = mainFileProvider + case "kubeconfig": + f.TemplateBody = mainKubeconfigProvider + default: + return fmt.Errorf("invalid provider %q: must be one of kubeconfig, namespace, cluster-api, file", f.Provider) + } + + return nil +} + +// leaderElectionID is the marker comment inserted into every template; the +// template engine replaces it via the ProjectNameMixin and DomainMixin values. +const leaderElectionID = `{{ .ProjectName }}.{{ .Domain }}` + +// ── Kubeconfig provider ─────────────────────────────────────────────────────── + +const mainKubeconfigProvider = `{{ .Boilerplate }} + +package main + +import ( + "crypto/tls" + "flag" + "os" + + // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) + _ "k8s.io/client-go/plugin/pkg/client/auth" + + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" + + // multicluster-runtime provides multi-cluster reconciliation support. + // https://pkg.go.dev/sigs.k8s.io/multicluster-runtime@{{ .MulticlusterRuntimeVersion }} + kubeconfigprovider "sigs.k8s.io/multicluster-runtime/providers/kubeconfig" + mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + // +kubebuilder:scaffold:imports +) + +var ( + scheme = runtime.NewScheme() + setupLog = ctrl.Log.WithName("setup") +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + // +kubebuilder:scaffold:scheme +} + +func main() { + var metricsAddr string + var enableLeaderElection bool + var probeAddr string + var secureMetrics bool + var enableHTTP2 bool + var tlsOpts []func(*tls.Config) + + flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to.") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager.") + flag.BoolVar(&secureMetrics, "metrics-secure", true, "Serve the metrics endpoint securely via HTTPS.") + flag.BoolVar(&enableHTTP2, "enable-http2", false, "Enable HTTP/2 for the metrics and webhook servers.") + + opts := zap.Options{Development: true} + opts.BindFlags(flag.CommandLine) + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + + disableHTTP2 := func(c *tls.Config) { + setupLog.Info("disabling http/2") + c.NextProtos = []string{"http/1.1"} + } + if !enableHTTP2 { + tlsOpts = append(tlsOpts, disableHTTP2) + } + webhookServer := webhook.NewServer(webhook.Options{TLSOpts: tlsOpts}) + + ctx := ctrl.SetupSignalHandler() + + cfg, err := ctrl.GetConfig() + if err != nil { + setupLog.Error(err, "unable to get kubeconfig") + os.Exit(1) + } + + // The kubeconfig provider watches kubeconfig Secrets and registers/deregisters + // clusters with the multicluster manager at runtime. + provider := kubeconfigprovider.New(kubeconfigprovider.Options{}) + + mgr, err := mcmanager.New(cfg, provider, ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{ + BindAddress: metricsAddr, + SecureServing: secureMetrics, + TLSOpts: tlsOpts, + }, + WebhookServer: webhookServer, + HealthProbeBindAddress: probeAddr, + LeaderElection: enableLeaderElection, + LeaderElectionID: "` + leaderElectionID + `", + }) + if err != nil { + setupLog.Error(err, "unable to start manager") + os.Exit(1) + } + + // +kubebuilder:scaffold:multicluster-builder + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up ready check") + os.Exit(1) + } + + if err := provider.SetupWithManager(ctx, mgr); err != nil { + setupLog.Error(err, "unable to setup kubeconfig provider") + os.Exit(1) + } + + setupLog.Info("starting manager") + if err := mgr.Start(ctx); err != nil { + setupLog.Error(err, "problem running manager") + os.Exit(1) + } +} +` + +// ── Namespace provider ──────────────────────────────────────────────────────── + +const mainNamespaceProvider = `{{ .Boilerplate }} + +package main + +import ( + "crypto/tls" + "flag" + "os" + + _ "k8s.io/client-go/plugin/pkg/client/auth" + + "golang.org/x/sync/errgroup" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cluster" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" + + // multicluster-runtime provides multi-cluster reconciliation support. + // https://pkg.go.dev/sigs.k8s.io/multicluster-runtime@{{ .MulticlusterRuntimeVersion }} + mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + nsprovider "sigs.k8s.io/multicluster-runtime/providers/namespace" + // +kubebuilder:scaffold:imports +) + +var ( + scheme = runtime.NewScheme() + setupLog = ctrl.Log.WithName("setup") +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + // +kubebuilder:scaffold:scheme +} + +func main() { + var metricsAddr string + var enableLeaderElection bool + var probeAddr string + var secureMetrics bool + var enableHTTP2 bool + var tlsOpts []func(*tls.Config) + + flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to.") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager.") + flag.BoolVar(&secureMetrics, "metrics-secure", true, "Serve the metrics endpoint securely via HTTPS.") + flag.BoolVar(&enableHTTP2, "enable-http2", false, "Enable HTTP/2 for the metrics and webhook servers.") + + opts := zap.Options{Development: true} + opts.BindFlags(flag.CommandLine) + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + + disableHTTP2 := func(c *tls.Config) { + setupLog.Info("disabling http/2") + c.NextProtos = []string{"http/1.1"} + } + if !enableHTTP2 { + tlsOpts = append(tlsOpts, disableHTTP2) + } + webhookServer := webhook.NewServer(webhook.Options{TLSOpts: tlsOpts}) + + ctx := ctrl.SetupSignalHandler() + + cfg, err := ctrl.GetConfig() + if err != nil { + setupLog.Error(err, "unable to get kubeconfig") + os.Exit(1) + } + + // The namespace provider treats each namespace as a separate "cluster". + local, err := cluster.New(cfg) + if err != nil { + setupLog.Error(err, "unable to create local cluster") + os.Exit(1) + } + + provider := nsprovider.New(local) + + mgr, err := mcmanager.New(cfg, provider, ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{ + BindAddress: metricsAddr, + SecureServing: secureMetrics, + TLSOpts: tlsOpts, + }, + WebhookServer: webhookServer, + HealthProbeBindAddress: probeAddr, + LeaderElection: enableLeaderElection, + LeaderElectionID: "` + leaderElectionID + `", + }) + if err != nil { + setupLog.Error(err, "unable to start manager") + os.Exit(1) + } + + // +kubebuilder:scaffold:multicluster-builder + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up ready check") + os.Exit(1) + } + + // Start provider and manager concurrently — the provider feeds cluster events + // into the manager so both must run simultaneously. + eg, ctx := errgroup.WithContext(ctx) + eg.Go(func() error { return provider.Start(ctx, mgr) }) + eg.Go(func() error { return mgr.Start(ctx) }) + if err := eg.Wait(); err != nil { + setupLog.Error(err, "problem running manager") + os.Exit(1) + } +} +` + +// ── Cluster API provider ────────────────────────────────────────────────────── + +const mainClusterAPIProvider = `{{ .Boilerplate }} + +package main + +import ( + "crypto/tls" + "flag" + "os" + + _ "k8s.io/client-go/plugin/pkg/client/auth" + + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" + + // multicluster-runtime provides multi-cluster reconciliation support. + // https://pkg.go.dev/sigs.k8s.io/multicluster-runtime@{{ .MulticlusterRuntimeVersion }} + capiprovider "sigs.k8s.io/multicluster-runtime/providers/cluster-api" + mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + // +kubebuilder:scaffold:imports +) + +var ( + scheme = runtime.NewScheme() + setupLog = ctrl.Log.WithName("setup") +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + // +kubebuilder:scaffold:scheme +} + +func main() { + var metricsAddr string + var enableLeaderElection bool + var probeAddr string + var secureMetrics bool + var enableHTTP2 bool + var kubeconfigSecretNamespace string + var tlsOpts []func(*tls.Config) + + flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to.") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager.") + flag.BoolVar(&secureMetrics, "metrics-secure", true, "Serve the metrics endpoint securely via HTTPS.") + flag.BoolVar(&enableHTTP2, "enable-http2", false, "Enable HTTP/2 for the metrics and webhook servers.") + flag.StringVar(&kubeconfigSecretNamespace, "kubeconfig-secret-namespace", "default", + "Namespace where Cluster API kubeconfig Secrets are stored.") + + opts := zap.Options{Development: true} + opts.BindFlags(flag.CommandLine) + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + + disableHTTP2 := func(c *tls.Config) { + setupLog.Info("disabling http/2") + c.NextProtos = []string{"http/1.1"} + } + if !enableHTTP2 { + tlsOpts = append(tlsOpts, disableHTTP2) + } + webhookServer := webhook.NewServer(webhook.Options{TLSOpts: tlsOpts}) + + ctx := ctrl.SetupSignalHandler() + + cfg, err := ctrl.GetConfig() + if err != nil { + setupLog.Error(err, "unable to get kubeconfig") + os.Exit(1) + } + + // The Cluster API provider reads kubeconfig Secrets created by Cluster API and + // registers each managed cluster with the multicluster manager. + // Requires sigs.k8s.io/cluster-api in your go.mod: + // go get sigs.k8s.io/cluster-api@latest + provider, err := capiprovider.New(cfg, capiprovider.Options{ + SecretNamespace: kubeconfigSecretNamespace, + }) + if err != nil { + setupLog.Error(err, "unable to create cluster-api provider") + os.Exit(1) + } + + mgr, err := mcmanager.New(cfg, provider, ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{ + BindAddress: metricsAddr, + SecureServing: secureMetrics, + TLSOpts: tlsOpts, + }, + WebhookServer: webhookServer, + HealthProbeBindAddress: probeAddr, + LeaderElection: enableLeaderElection, + LeaderElectionID: "` + leaderElectionID + `", + }) + if err != nil { + setupLog.Error(err, "unable to start manager") + os.Exit(1) + } + + // +kubebuilder:scaffold:multicluster-builder + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up ready check") + os.Exit(1) + } + + if err := provider.SetupWithManager(ctx, mgr); err != nil { + setupLog.Error(err, "unable to setup cluster-api provider") + os.Exit(1) + } + + setupLog.Info("starting manager") + if err := mgr.Start(ctx); err != nil { + setupLog.Error(err, "problem running manager") + os.Exit(1) + } +} +` + +// ── File provider ───────────────────────────────────────────────────────────── + +const mainFileProvider = `{{ .Boilerplate }} + +package main + +import ( + "crypto/tls" + "flag" + "os" + + _ "k8s.io/client-go/plugin/pkg/client/auth" + + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" + + // multicluster-runtime provides multi-cluster reconciliation support. + // https://pkg.go.dev/sigs.k8s.io/multicluster-runtime@{{ .MulticlusterRuntimeVersion }} + fileprovider "sigs.k8s.io/multicluster-runtime/providers/file" + mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + // +kubebuilder:scaffold:imports +) + +var ( + scheme = runtime.NewScheme() + setupLog = ctrl.Log.WithName("setup") +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + // +kubebuilder:scaffold:scheme +} + +func main() { + var metricsAddr string + var enableLeaderElection bool + var probeAddr string + var secureMetrics bool + var enableHTTP2 bool + var kubeconfigDir string + var tlsOpts []func(*tls.Config) + + flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to.") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager.") + flag.BoolVar(&secureMetrics, "metrics-secure", true, "Serve the metrics endpoint securely via HTTPS.") + flag.BoolVar(&enableHTTP2, "enable-http2", false, "Enable HTTP/2 for the metrics and webhook servers.") + flag.StringVar(&kubeconfigDir, "kubeconfig-dir", "{{ .KubeconfigDir }}", + "Directory containing one kubeconfig file per cluster.") + + opts := zap.Options{Development: true} + opts.BindFlags(flag.CommandLine) + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + + disableHTTP2 := func(c *tls.Config) { + setupLog.Info("disabling http/2") + c.NextProtos = []string{"http/1.1"} + } + if !enableHTTP2 { + tlsOpts = append(tlsOpts, disableHTTP2) + } + webhookServer := webhook.NewServer(webhook.Options{TLSOpts: tlsOpts}) + + ctx := ctrl.SetupSignalHandler() + + cfg, err := ctrl.GetConfig() + if err != nil { + setupLog.Error(err, "unable to get kubeconfig") + os.Exit(1) + } + + // The file provider reads one kubeconfig file per cluster from a directory. + // Useful for static cluster lists, CI environments, and air-gapped setups. + provider, err := fileprovider.New(fileprovider.Options{Dir: kubeconfigDir}) + if err != nil { + setupLog.Error(err, "unable to create file provider") + os.Exit(1) + } + + mgr, err := mcmanager.New(cfg, provider, ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{ + BindAddress: metricsAddr, + SecureServing: secureMetrics, + TLSOpts: tlsOpts, + }, + WebhookServer: webhookServer, + HealthProbeBindAddress: probeAddr, + LeaderElection: enableLeaderElection, + LeaderElectionID: "` + leaderElectionID + `", + }) + if err != nil { + setupLog.Error(err, "unable to start manager") + os.Exit(1) + } + + // +kubebuilder:scaffold:multicluster-builder + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up ready check") + os.Exit(1) + } + + if err := provider.SetupWithManager(ctx, mgr); err != nil { + setupLog.Error(err, "unable to setup file provider") + os.Exit(1) + } + + setupLog.Info("starting manager") + if err := mgr.Start(ctx); err != nil { + setupLog.Error(err, "problem running manager") + os.Exit(1) + } +} +` diff --git a/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/internal/templates/cmd/main_updater.go b/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/internal/templates/cmd/main_updater.go new file mode 100644 index 00000000000..d528b8e5074 --- /dev/null +++ b/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/internal/templates/cmd/main_updater.go @@ -0,0 +1,107 @@ +/* +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 cmd + +import ( + "fmt" + + "sigs.k8s.io/kubebuilder/v4/pkg/machinery" + "sigs.k8s.io/kubebuilder/v4/pkg/model/resource" +) + +var _ machinery.Inserter = &MainUpdater{} + +// MainUpdater inserts the multicluster-aware reconciler setup into cmd/main.go. +// It targets the // +kubebuilder:scaffold:multicluster-builder marker so that +// go/v4's standard MainUpdater (which uses mgr.GetClient()) does NOT run at this +// marker and generate incompatible code. +type MainUpdater struct { + machinery.RepositoryMixin + machinery.MultiGroupMixin + machinery.ResourceMixin + + // ControllerName is the name of the controller (defaults to Kind). + ControllerName string + + // WireController indicates that this resource has a controller to wire. + WireController bool +} + +// GetPath implements machinery.Builder. +func (*MainUpdater) GetPath() string { return defaultMainPath } + +// GetIfExistsAction implements machinery.Builder. +func (*MainUpdater) GetIfExistsAction() machinery.IfExistsAction { + return machinery.OverwriteFile +} + +// GetIfNotExistsAction implements machinery.HasIfNotExistsAction. +// If cmd/main.go doesn't exist yet (e.g. in unit tests with empty filesystems), +// silently skip rather than error. +func (*MainUpdater) GetIfNotExistsAction() machinery.IfNotExistsAction { + return machinery.IgnoreFile +} + +const mcBuilderMarker = "multicluster-builder" + +// GetMarkers implements machinery.Inserter. +func (f *MainUpdater) GetMarkers() []machinery.Marker { + return []machinery.Marker{ + machinery.NewMarkerFor(defaultMainPath, mcBuilderMarker), + } +} + +const mcReconcilerSetupCodeFragment = `if err := (&controller.%s{ + Client: mgr.GetLocalManager().GetClient(), + Scheme: mgr.GetLocalManager().GetScheme(), +}).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "Failed to create controller", "controller", "%s") + os.Exit(1) +} +` + +const mcMultiGroupReconcilerSetupCodeFragment = `if err := (&%scontroller.%s{ + Client: mgr.GetLocalManager().GetClient(), + Scheme: mgr.GetLocalManager().GetScheme(), +}).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "Failed to create controller", "controller", "%s") + os.Exit(1) +} +` + +// GetCodeFragments implements machinery.Inserter. +func (f *MainUpdater) GetCodeFragments() machinery.CodeFragmentsMap { + fragments := make(machinery.CodeFragmentsMap, 1) + + if f.Resource == nil || !f.WireController { + return fragments + } + + reconcilerName := resource.NormalizeReconcilerName(f.ControllerName, f.Resource.Kind) + controllerName := resource.GetControllerName(f.ControllerName, f.Resource.Kind, f.Resource.Group, f.MultiGroup) + + var setup string + if !f.MultiGroup || f.Resource.Group == "" { + setup = fmt.Sprintf(mcReconcilerSetupCodeFragment, reconcilerName, controllerName) + } else { + setup = fmt.Sprintf(mcMultiGroupReconcilerSetupCodeFragment, + f.Resource.PackageName(), reconcilerName, controllerName) + } + + fragments[machinery.NewMarkerFor(defaultMainPath, mcBuilderMarker)] = []string{setup} + return fragments +} diff --git a/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/internal/templates/controllers/controller.go b/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/internal/templates/controllers/controller.go new file mode 100644 index 00000000000..30e95223da0 --- /dev/null +++ b/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/internal/templates/controllers/controller.go @@ -0,0 +1,131 @@ +/* +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 controllers + +import ( + "path/filepath" + + "sigs.k8s.io/kubebuilder/v4/pkg/machinery" + "sigs.k8s.io/kubebuilder/v4/pkg/model/resource" +) + +var _ machinery.Template = &Controller{} + +// Controller scaffolds a multicluster-aware controller file. +type Controller struct { + machinery.TemplateMixin + machinery.MultiGroupMixin + machinery.BoilerplateMixin + machinery.ResourceMixin + machinery.ProjectNameMixin + + Force bool + + ControllerName string +} + +// SetTemplateDefaults implements machinery.Template. +func (f *Controller) SetTemplateDefaults() error { + if f.Path == "" { + fileName := "%[kind]_controller.go" + if f.ControllerName != "" { + fileName = resource.NormalizeFileName(f.ControllerName) + "_controller.go" + } + + if f.MultiGroup && f.Resource.Group != "" { + f.Path = filepath.Join("internal", "controller", "%[group]", fileName) + } else { + f.Path = filepath.Join("internal", "controller", fileName) + } + } + + f.Path = f.Resource.Replacer().Replace(f.Path) + + f.TemplateBody = controllerTemplate + + if f.Force { + f.IfExistsAction = machinery.OverwriteFile + } else { + f.IfExistsAction = machinery.Error + } + + return nil +} + +// ReconcilerName returns the name for the reconciler struct. +func (f *Controller) ReconcilerName() string { + return resource.NormalizeReconcilerName(f.ControllerName, f.Resource.Kind) +} + +// ControllerRuntimeName returns the controller runtime name used in Named(). +func (f *Controller) ControllerRuntimeName() string { + return resource.GetControllerName(f.ControllerName, f.Resource.Kind, f.Resource.Group, f.MultiGroup) +} + +//nolint:lll +const controllerTemplate = `{{ .Boilerplate }} + +package {{ if and .MultiGroup .Resource.Group }}{{ .Resource.PackageName }}{{ else }}controller{{ end }} + +import ( + "context" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + mcbuilder "sigs.k8s.io/multicluster-runtime/pkg/builder" + mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" + {{ if not (isEmptyStr .Resource.Path) -}} + {{ .Resource.ImportAlias }} "{{ .Resource.Path }}" + {{- end }} +) + +// {{ .ReconcilerName }} reconciles a {{ .Resource.Kind }} object across clusters +type {{ .ReconcilerName }} struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups={{ .Resource.QualifiedGroup }},resources={{ .Resource.Plural }},verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups={{ .Resource.QualifiedGroup }},resources={{ .Resource.Plural }}/status,verbs=get;update;patch +// +kubebuilder:rbac:groups={{ .Resource.QualifiedGroup }},resources={{ .Resource.Plural }}/finalizers,verbs=update + +// Reconcile reconciles {{ .Resource.Kind }} objects across all clusters managed by the multicluster provider. +// req.ClusterName identifies which cluster the event originated from. +func (r *{{ .ReconcilerName }}) Reconcile(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) { + _ = logf.FromContext(ctx) + + // TODO(user): your logic here + + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the multicluster Manager. +func (r *{{ .ReconcilerName }}) SetupWithManager(mgr mcmanager.Manager) error { + return mcbuilder.ControllerManagedBy(mgr). + {{ if not (isEmptyStr .Resource.Path) -}} + For(&{{ .Resource.ImportAlias }}.{{ .Resource.Kind }}{}). + {{- else -}} + // Uncomment the following line adding a pointer to an instance of the controlled resource as an argument + // For(). + {{- end }} + Named("{{ .ControllerRuntimeName }}"). + Complete(r) +} +` diff --git a/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/internal/templates/controllers/controller_test_template.go b/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/internal/templates/controllers/controller_test_template.go new file mode 100644 index 00000000000..e251b71da24 --- /dev/null +++ b/pkg/plugins/optional/multicluster-runtime/v1alpha/scaffolds/internal/templates/controllers/controller_test_template.go @@ -0,0 +1,146 @@ +/* +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 controllers + +import ( + "path/filepath" + + "sigs.k8s.io/kubebuilder/v4/pkg/machinery" +) + +var _ machinery.Template = &ControllerTest{} + +// ControllerTest scaffolds the controller unit test file, replacing the +// go/v4 version so that it uses mcreconcile.Request instead of reconcile.Request. +type ControllerTest struct { + machinery.TemplateMixin + machinery.MultiGroupMixin + machinery.BoilerplateMixin + machinery.ResourceMixin + + Force bool + + DoAPI bool +} + +// SetTemplateDefaults implements machinery.Template. +func (f *ControllerTest) SetTemplateDefaults() error { + if f.Path == "" { + if f.MultiGroup && f.Resource.Group != "" { + f.Path = filepath.Join("internal", "controller", "%[group]", "%[kind]_controller_test.go") + } else { + f.Path = filepath.Join("internal", "controller", "%[kind]_controller_test.go") + } + } + + f.Path = f.Resource.Replacer().Replace(f.Path) + + f.TemplateBody = mcControllerTestTemplate + + if f.Force { + f.IfExistsAction = machinery.OverwriteFile + } + + return nil +} + +const mcControllerTestTemplate = `{{ .Boilerplate }} + +{{if and .MultiGroup .Resource.Group }} +package {{ .Resource.PackageName }} +{{else}} +package controller +{{end}} + +import ( + {{ if .DoAPI -}} + "context" + {{- end }} + . "github.com/onsi/ginkgo/v2" + {{ if .DoAPI -}} + + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + {{ if not (isEmptyStr .Resource.Path) -}} + {{ .Resource.ImportAlias }} "{{ .Resource.Path }}" + {{- end }} + {{- end }} +) + +var _ = Describe("{{ .Resource.Kind }} Controller", func() { + Context("When reconciling a resource", func() { + {{ if .DoAPI -}} + const resourceName = "test-resource" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", // TODO(user): Modify as needed + } + {{ lower .Resource.Kind }} := &{{ .Resource.ImportAlias }}.{{ .Resource.Kind }}{} + + BeforeEach(func() { + By("creating the custom resource for the Kind {{ .Resource.Kind }}") + err := k8sClient.Get(ctx, typeNamespacedName, {{ lower .Resource.Kind }}) + if err != nil && errors.IsNotFound(err) { + resource := &{{ .Resource.ImportAlias }}.{{ .Resource.Kind }}{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + // TODO(user): Specify other spec details if needed. + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + + AfterEach(func() { + // TODO(user): Cleanup logic after each test, like removing the resource instance. + resource := &{{ .Resource.ImportAlias }}.{{ .Resource.Kind }}{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance {{ .Resource.Kind }}") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + {{- end }} + It("should successfully reconcile the resource", func() { + {{ if .DoAPI -}} + By("Reconciling the created resource") + controllerReconciler := &{{ .Resource.Kind }}Reconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, mcreconcile.Request{ + Request: reconcile.Request{NamespacedName: typeNamespacedName}, + ClusterName: "local", + }) + Expect(err).NotTo(HaveOccurred()) + {{- end }} + // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. + // Example: If you expect a certain status condition after reconciliation, verify it here. + }) + }) +}) +` diff --git a/pkg/plugins/optional/multicluster-runtime/v1alpha/suite_test.go b/pkg/plugins/optional/multicluster-runtime/v1alpha/suite_test.go new file mode 100644 index 00000000000..592b236daf3 --- /dev/null +++ b/pkg/plugins/optional/multicluster-runtime/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 TestMulticlusterRuntimePlugin(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "multicluster-runtime/v1alpha Plugin Suite") +} diff --git a/test/e2e/all/plugin_multicluster_test.go b/test/e2e/all/plugin_multicluster_test.go new file mode 100644 index 00000000000..c2bfece8d84 --- /dev/null +++ b/test/e2e/all/plugin_multicluster_test.go @@ -0,0 +1,129 @@ +/* +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 all + +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" +) + +// Test specs for multicluster-runtime/v1-alpha plugin +var _ = Describe("kubebuilder", func() { + Context("plugin multicluster-runtime/v1-alpha", 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()) + }) + + AfterEach(func() { + By("removing controller image and working dir") + kbc.Destroy() + }) + + It("should scaffold and build a project with the kubeconfig provider", func() { + By("initialising a go/v4 + multicluster-runtime/v1-alpha project") + Expect(kbc.Init( + "--plugins", "go/v4,multicluster-runtime.kubebuilder.io/v1-alpha", + "--domain", kbc.Domain, + )).To(Succeed()) + + By("creating a multicluster-aware API and controller") + Expect(kbc.CreateAPI( + "--plugins", "go/v4,multicluster-runtime.kubebuilder.io/v1-alpha", + "--group", kbc.Group, + "--version", kbc.Version, + "--kind", kbc.Kind, + "--controller", + "--resource", + "--make=false", + )).To(Succeed()) + + By("verifying cmd/main.go uses mcmanager.New") + mainPath := filepath.Join(kbc.Dir, "cmd", "main.go") + mainBytes, err := os.ReadFile(mainPath) //nolint:gosec + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainBytes)).To(ContainSubstring("mcmanager.New")) + Expect(string(mainBytes)).To(ContainSubstring("sigs.k8s.io/multicluster-runtime")) + + By("verifying the controller uses mcreconcile.Request") + controllerGlob := filepath.Join(kbc.Dir, "internal", "controller", "*.go") + matches, err := filepath.Glob(controllerGlob) + Expect(err).NotTo(HaveOccurred()) + Expect(matches).NotTo(BeEmpty()) + controllerBytes, err := os.ReadFile(matches[0]) //nolint:gosec + Expect(err).NotTo(HaveOccurred()) + Expect(string(controllerBytes)).To(ContainSubstring("mcreconcile.Request")) + Expect(string(controllerBytes)).To(ContainSubstring("mcbuilder.ControllerManagedBy")) + + By("verifying the project builds without errors") + Expect(kbc.Make("build")).To(Succeed()) + }) + + It("should scaffold and build a project with the namespace provider", func() { + By("initialising a go/v4 + multicluster-runtime/v1-alpha project with namespace provider") + Expect(kbc.Init( + "--plugins", "go/v4,multicluster-runtime.kubebuilder.io/v1-alpha", + "--domain", kbc.Domain, + "--provider", "namespace", + )).To(Succeed()) + + By("verifying cmd/main.go uses the namespace provider") + mainPath := filepath.Join(kbc.Dir, "cmd", "main.go") + mainBytes, err := os.ReadFile(mainPath) //nolint:gosec + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainBytes)).To(ContainSubstring("nsprovider")) + + By("verifying the project builds without errors") + Expect(kbc.Make("build")).To(Succeed()) + }) + + It("should allow switching providers with kubebuilder edit", func() { + By("initialising a project with the kubeconfig provider") + Expect(kbc.Init( + "--plugins", "go/v4,multicluster-runtime.kubebuilder.io/v1-alpha", + "--domain", kbc.Domain, + "--provider", "kubeconfig", + )).To(Succeed()) + + By("switching to the namespace provider via kubebuilder edit") + Expect(kbc.Edit( + "--plugins", "multicluster-runtime.kubebuilder.io/v1-alpha", + "--provider", "namespace", + )).To(Succeed()) + + By("verifying cmd/main.go now uses the namespace provider") + mainPath := filepath.Join(kbc.Dir, "cmd", "main.go") + mainBytes, err := os.ReadFile(mainPath) //nolint:gosec + Expect(err).NotTo(HaveOccurred()) + Expect(string(mainBytes)).To(ContainSubstring("nsprovider")) + Expect(string(mainBytes)).NotTo(ContainSubstring("kubeconfigprovider")) + + By("verifying the project still builds after switching providers") + Expect(kbc.Make("build")).To(Succeed()) + }) + }) +})