Browse Source

feat(esoctl): adds bootstrap generator commands (#5539)

* feat(esoctl): adds bootstrap generator commands

Signed-off-by: Gustavo Carvalho <gustavo@externalsecrets.com>

* fix: lint

Signed-off-by: Gustavo Carvalho <gustavo@externalsecrets.com>

* fix: not generating go mod

Signed-off-by: Gustavo Carvalho <gustavo@externalsecrets.com>

* fix: add resolver entry for new generator

Signed-off-by: Gustavo Carvalho <gustavo@externalsecrets.com>

* fix: adds missing api kind registration

Signed-off-by: Gustavo Carvalho <gustavo@externalsecrets.com>

* fix: use octal instead of decimals for perms

Signed-off-by: Gustavo Carvalho <gustavo@externalsecrets.com>

* docs: document bootstrap generator cmd

Signed-off-by: Gustavo Carvalho <gustavo@externalsecrets.com>

---------

Signed-off-by: Gustavo Carvalho <gustavo@externalsecrets.com>
Co-authored-by: Gergely Brautigam <skarlso777@gmail.com>
Gustavo Fernandes de Carvalho 5 months ago
parent
commit
580043f98a

+ 1 - 1
Makefile

@@ -437,7 +437,7 @@ TILT ?= $(LOCALBIN)/tilt
 CTY ?= $(LOCALBIN)/cty
 ENVTEST ?= $(LOCALBIN)/setup-envtest
 GOLANGCI_LINT ?= $(LOCALBIN)/golangci-lint
-
+LINT_TARGET ?= ""
 ## Tool Versions
 GOLANGCI_VERSION := 2.4.0
 KUBERNETES_VERSION := 1.33.x

+ 111 - 0
cmd/esoctl/bootstrap.go

@@ -0,0 +1,111 @@
+/*
+Copyright © 2025 ESO Maintainer Team
+
+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
+
+	https://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 main
+
+import (
+	"fmt"
+	"os"
+	"regexp"
+	"strings"
+
+	"github.com/spf13/cobra"
+
+	"github.com/external-secrets/external-secrets/cmd/esoctl/generator"
+)
+
+var (
+	generatorName        string
+	generatorDescription string
+	generatorPackage     string
+)
+
+func init() {
+	bootstrapCmd.AddCommand(bootstrapGeneratorCmd)
+	bootstrapGeneratorCmd.Flags().StringVar(&generatorName, "name", "", "Name of the generator (e.g., MyGenerator)")
+	bootstrapGeneratorCmd.Flags().StringVar(&generatorDescription, "description", "", "Description of the generator")
+	bootstrapGeneratorCmd.Flags().StringVar(&generatorPackage, "package", "", "Package name (default: lowercase of name)")
+	_ = bootstrapGeneratorCmd.MarkFlagRequired("name")
+}
+
+var bootstrapCmd = &cobra.Command{
+	Use:   "bootstrap",
+	Short: "Bootstrap new resources for external-secrets",
+	Long:  `Bootstrap new resources like generators for external-secrets operator.`,
+	Run: func(cmd *cobra.Command, _ []string) {
+		_ = cmd.Usage()
+	},
+}
+
+var bootstrapGeneratorCmd = &cobra.Command{
+	Use:   "generator",
+	Short: "Bootstrap a new generator",
+	Long:  `Bootstrap a new generator with CRD definition and provider implementation.`,
+	RunE:  bootstrapGeneratorRun,
+}
+
+func bootstrapGeneratorRun(_ *cobra.Command, _ []string) error {
+	// Validate generator name
+	if !regexp.MustCompile(`^[A-Z][a-zA-Z0-9]*$`).MatchString(generatorName) {
+		return fmt.Errorf("generator name must be PascalCase and start with an uppercase letter")
+	}
+
+	// Set default package name if not provided
+	if generatorPackage == "" {
+		generatorPackage = strings.ToLower(generatorName)
+	}
+
+	// Set default description if not provided
+	if generatorDescription == "" {
+		generatorDescription = fmt.Sprintf("%s generator", generatorName)
+	}
+
+	// Get root directory
+	wd, err := os.Getwd()
+	if err != nil {
+		return fmt.Errorf("failed to get working directory: %w", err)
+	}
+
+	// Try to find the root directory
+	rootDir := generator.FindRootDir(wd)
+	if rootDir == "" {
+		return fmt.Errorf("could not find repository root directory")
+	}
+
+	// Create generator configuration
+	cfg := generator.Config{
+		GeneratorName: generatorName,
+		PackageName:   generatorPackage,
+		Description:   generatorDescription,
+		GeneratorKind: "GeneratorKind" + generatorName,
+	}
+
+	// Bootstrap the generator
+	if err := generator.Bootstrap(rootDir, cfg); err != nil {
+		return err
+	}
+
+	fmt.Printf("✓ Successfully bootstrapped generator: %s\n", generatorName)
+	fmt.Printf("\nNext steps:\n")
+	fmt.Printf("1. Review and customize: apis/generators/v1alpha1/types_%s.go\n", generatorPackage)
+	fmt.Printf("2. Implement the generator logic in: generators/v1/%s/%s.go\n", generatorPackage, generatorPackage)
+	fmt.Printf("3. Run: go mod tidy\n")
+	fmt.Printf("4. Run: make generate\n")
+	fmt.Printf("5. Run: make manifests\n")
+	fmt.Printf("6. Add tests for your generator\n")
+
+	return nil
+}

+ 513 - 0
cmd/esoctl/generator/bootstrap.go

@@ -0,0 +1,513 @@
+/*
+Copyright © 2025 ESO Maintainer Team
+
+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
+
+	https://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 generator provides functionality for bootstrapping new generators.
+package generator
+
+import (
+	"embed"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"text/template"
+)
+
+//go:embed templates/*.tmpl
+var templates embed.FS
+
+// Config holds the configuration for bootstrapping a generator.
+type Config struct {
+	GeneratorName string
+	PackageName   string
+	Description   string
+	GeneratorKind string
+}
+
+// Bootstrap creates a new generator with all necessary files.
+func Bootstrap(rootDir string, cfg Config) error {
+	// Create generator CRD
+	if err := createGeneratorCRD(rootDir, cfg); err != nil {
+		return fmt.Errorf("failed to create CRD: %w", err)
+	}
+
+	// Create generator implementation
+	if err := createGeneratorImplementation(rootDir, cfg); err != nil {
+		return fmt.Errorf("failed to create implementation: %w", err)
+	}
+
+	// Update register file
+	if err := updateRegisterFile(rootDir, cfg); err != nil {
+		return fmt.Errorf("failed to update register file: %w", err)
+	}
+
+	// Update types_cluster.go
+	if err := updateTypesClusterFile(rootDir, cfg); err != nil {
+		return fmt.Errorf("failed to update types_cluster.go: %w", err)
+	}
+
+	// Update main go.mod
+	if err := updateMainGoMod(rootDir, cfg); err != nil {
+		return fmt.Errorf("failed to update main go.mod: %w", err)
+	}
+
+	// Update resolver file
+	if err := updateResolverFile(rootDir, cfg); err != nil {
+		return fmt.Errorf("failed to update resolver file: %w", err)
+	}
+
+	// Update register kind file
+	if err := updateRegisterKindFile(rootDir, cfg); err != nil {
+		return fmt.Errorf("failed to update register kind file: %w", err)
+	}
+
+	return nil
+}
+
+func createGeneratorCRD(rootDir string, cfg Config) error {
+	crdDir := filepath.Join(rootDir, "apis", "generators", "v1alpha1")
+	crdFile := filepath.Join(crdDir, fmt.Sprintf("types_%s.go", cfg.PackageName))
+
+	// Check if file already exists
+	if _, err := os.Stat(crdFile); err == nil {
+		return fmt.Errorf("CRD file already exists: %s", crdFile)
+	}
+
+	tmplContent, err := templates.ReadFile("templates/crd.go.tmpl")
+	if err != nil {
+		return fmt.Errorf("failed to read template: %w", err)
+	}
+
+	tmpl := template.Must(template.New("crd").Parse(string(tmplContent)))
+	f, err := os.Create(filepath.Clean(crdFile))
+	if err != nil {
+		return err
+	}
+	defer func() { _ = f.Close() }()
+
+	if err := tmpl.Execute(f, cfg); err != nil {
+		return err
+	}
+
+	fmt.Printf("✓ Created CRD: %s\n", crdFile)
+	return nil
+}
+
+func createGeneratorImplementation(rootDir string, cfg Config) error {
+	genDir := filepath.Join(rootDir, "generators", "v1", cfg.PackageName)
+	if err := os.MkdirAll(genDir, 0o750); err != nil {
+		return err
+	}
+
+	// Create main generator file
+	genFile := filepath.Join(genDir, fmt.Sprintf("%s.go", cfg.PackageName))
+	if _, err := os.Stat(genFile); err == nil {
+		return fmt.Errorf("implementation file already exists: %s", genFile)
+	}
+
+	if err := createFromTemplate("templates/implementation.go.tmpl", genFile, cfg); err != nil {
+		return err
+	}
+	fmt.Printf("✓ Created implementation: %s\n", genFile)
+
+	// Create test file
+	testFile := filepath.Join(genDir, fmt.Sprintf("%s_test.go", cfg.PackageName))
+	if err := createFromTemplate("templates/test.go.tmpl", testFile, cfg); err != nil {
+		return err
+	}
+	fmt.Printf("✓ Created test file: %s\n", testFile)
+
+	// Create go.mod
+	goModFile := filepath.Join(genDir, "go.mod")
+	if err := createFromTemplate("templates/go.mod.tmpl", goModFile, cfg); err != nil {
+		return err
+	}
+	fmt.Printf("✓ Created go.mod: %s\n", goModFile)
+
+	// Create empty go.sum
+	goSumFile := filepath.Join(genDir, "go.sum")
+	if err := os.WriteFile(goSumFile, []byte(""), 0o600); err != nil {
+		return err
+	}
+	fmt.Printf("✓ Created go.sum: %s\n", goSumFile)
+
+	return nil
+}
+
+func createFromTemplate(tmplPath, outputFile string, cfg Config) error {
+	tmplContent, err := templates.ReadFile(tmplPath)
+	if err != nil {
+		return fmt.Errorf("failed to read template %s: %w", tmplPath, err)
+	}
+
+	tmpl := template.Must(template.New(filepath.Base(tmplPath)).Parse(string(tmplContent)))
+	f, err := os.Create(filepath.Clean(outputFile))
+	if err != nil {
+		return err
+	}
+	defer func() { _ = f.Close() }()
+	return tmpl.Execute(f, cfg)
+}
+
+func updateRegisterFile(rootDir string, cfg Config) error {
+	registerFile := filepath.Join(rootDir, "pkg", "register", "generators.go")
+
+	data, err := os.ReadFile(filepath.Clean(registerFile))
+	if err != nil {
+		return err
+	}
+
+	content := string(data)
+
+	// Check if already registered
+	if strings.Contains(content, fmt.Sprintf("%q", cfg.PackageName)) {
+		fmt.Printf("⚠ Generator already registered in %s\n", registerFile)
+		return nil
+	}
+
+	// Add import
+	importLine := fmt.Sprintf("\t%s \"github.com/external-secrets/external-secrets/generators/v1/%s\"",
+		cfg.PackageName, cfg.PackageName)
+
+	// Find the last import before the closing parenthesis
+	lines := strings.Split(content, "\n")
+	newLines := make([]string, 0, len(lines)+2)
+	importAdded := false
+	registerAdded := false
+
+	for i, line := range lines {
+		newLines = append(newLines, line)
+
+		// Add import after the last generator import
+		if !importAdded && strings.Contains(line, "\"github.com/external-secrets/external-secrets/generators/v1/") {
+			// Look ahead to see if next line is still an import or closing paren
+			if i+1 < len(lines) && strings.TrimSpace(lines[i+1]) == ")" {
+				newLines = append(newLines, importLine)
+				importAdded = true
+			}
+		}
+
+		// Add register call after the last Register call
+		if !registerAdded && strings.Contains(line, "genv1alpha1.Register(") {
+			// Look ahead to see if next line is closing brace
+			if i+1 < len(lines) && strings.TrimSpace(lines[i+1]) == "}" {
+				registerLine := fmt.Sprintf("\tgenv1alpha1.Register(%s.Kind(), %s.NewGenerator())",
+					cfg.PackageName, cfg.PackageName)
+				newLines = append(newLines, registerLine)
+				registerAdded = true
+			}
+		}
+	}
+
+	if !importAdded || !registerAdded {
+		return fmt.Errorf("failed to add import or register call to %s", registerFile)
+	}
+
+	if err := os.WriteFile(filepath.Clean(registerFile), []byte(strings.Join(newLines, "\n")), 0o600); err != nil {
+		return err
+	}
+
+	fmt.Printf("✓ Updated register file: %s\n", registerFile)
+	return nil
+}
+
+func updateTypesClusterFile(rootDir string, cfg Config) error {
+	typesClusterFile := filepath.Join(rootDir, "apis", "generators", "v1alpha1", "types_cluster.go")
+
+	data, err := os.ReadFile(filepath.Clean(typesClusterFile))
+	if err != nil {
+		return err
+	}
+
+	content := string(data)
+
+	// Check if already exists
+	if strings.Contains(content, cfg.GeneratorKind) {
+		fmt.Printf("⚠ Generator kind already exists in types_cluster.go\n")
+		return nil
+	}
+
+	lines := strings.Split(content, "\n")
+	newLines := make([]string, 0, len(lines)+2)
+	enumAdded := false
+	constAdded := false
+	specAdded := false
+
+	for i, line := range lines {
+		// Update the enum validation annotation
+		if !enumAdded && strings.Contains(line, "+kubebuilder:validation:Enum=") {
+			// Add the new generator to the enum list
+			line = strings.TrimRight(line, "\n")
+			if strings.HasSuffix(line, "Grafana") {
+				line = line + ";" + cfg.GeneratorName
+			}
+			enumAdded = true
+		}
+
+		newLines = append(newLines, line)
+
+		// Add const after the last GeneratorKind const
+		if !constAdded && strings.Contains(line, "GeneratorKind") && strings.Contains(line, "GeneratorKind = \"") {
+			// Look ahead to check if next line is closing paren
+			if i+1 < len(lines) && strings.TrimSpace(lines[i+1]) == ")" {
+				constLine := fmt.Sprintf("\t// %s represents a %s generator.",
+					cfg.GeneratorKind, strings.ToLower(cfg.GeneratorName))
+				newLines = append(newLines, constLine)
+				constValueLine := fmt.Sprintf("\t%s GeneratorKind = %q",
+					cfg.GeneratorKind, cfg.GeneratorName)
+				newLines = append(newLines, constValueLine)
+				constAdded = true
+			}
+		}
+
+		// Add spec field to GeneratorSpec struct
+		if !specAdded && strings.Contains(line, "Spec") && strings.Contains(line, "`json:") && strings.Contains(line, "omitempty") {
+			// Look ahead to check if next line is closing brace of the struct
+			if i+1 < len(lines) && strings.TrimSpace(lines[i+1]) == "}" {
+				// Add the new spec field
+				jsonTag := strings.ToLower(cfg.GeneratorName) + "Spec"
+				specLine := fmt.Sprintf("\t%sSpec             *%sSpec             `json:\"%s,omitempty\"`",
+					cfg.GeneratorName, cfg.GeneratorName, jsonTag)
+				newLines = append(newLines, specLine)
+				specAdded = true
+			}
+		}
+	}
+
+	if !enumAdded || !constAdded || !specAdded {
+		fmt.Printf("⚠ Warning: Could not fully update types_cluster.go. Please manually add:\n")
+		if !enumAdded {
+			fmt.Printf("   1. Add '%s' to the kubebuilder:validation:Enum annotation\n", cfg.GeneratorName)
+		}
+		if !constAdded {
+			fmt.Printf("   2. Add the const: %s GeneratorKind = \"%s\"\n", cfg.GeneratorKind, cfg.GeneratorName)
+		}
+		if !specAdded {
+			fmt.Printf("   3. Add to GeneratorSpec struct: %sSpec *%sSpec `json:\"%sSpec,omitempty\"`\n",
+				cfg.GeneratorName, cfg.GeneratorName, strings.ToLower(cfg.GeneratorName))
+		}
+	} else {
+		if err := os.WriteFile(filepath.Clean(typesClusterFile), []byte(strings.Join(newLines, "\n")), 0o600); err != nil {
+			return err
+		}
+		fmt.Printf("✓ Updated types_cluster.go\n")
+	}
+
+	return nil
+}
+
+func updateMainGoMod(rootDir string, cfg Config) error {
+	goModFile := filepath.Join(rootDir, "go.mod")
+
+	data, err := os.ReadFile(filepath.Clean(goModFile))
+	if err != nil {
+		return err
+	}
+
+	content := string(data)
+	replaceLine := fmt.Sprintf("\tgithub.com/external-secrets/external-secrets/generators/v1/%s => ./generators/v1/%s",
+		cfg.PackageName, cfg.PackageName)
+
+	// Check if already exists
+	if strings.Contains(content, replaceLine) {
+		fmt.Printf("⚠ Replace directive already exists in go.mod\n")
+		return nil
+	}
+
+	lines := strings.Split(content, "\n")
+	newLines := make([]string, 0, len(lines)+1)
+	added := false
+	lastGeneratorIdx := -1
+
+	// First pass: find where to insert
+	for i, line := range lines {
+		if strings.Contains(line, "github.com/external-secrets/external-secrets/generators/v1/") {
+			lastGeneratorIdx = i
+			// Extract the package name from the current line
+			currentPkg := extractGeneratorPackage(line)
+			if currentPkg != "" && cfg.PackageName < currentPkg && !added {
+				// Insert before this line (alphabetically)
+				newLines = append(newLines, replaceLine)
+				added = true
+			}
+		}
+
+		newLines = append(newLines, line)
+
+		// If this was the last generator and we haven't added yet, add after it
+		if i == lastGeneratorIdx && !added && lastGeneratorIdx != -1 {
+			// Check if next line is NOT a generator (meaning this is the last one)
+			if i+1 >= len(lines) || !strings.Contains(lines[i+1], "github.com/external-secrets/external-secrets/generators/v1/") {
+				newLines = append(newLines, replaceLine)
+				added = true
+			}
+		}
+	}
+
+	// This shouldn't happen in practice but handle it
+	if !added {
+		return fmt.Errorf("could not find appropriate position to insert replace directive")
+	}
+
+	if err := os.WriteFile(filepath.Clean(goModFile), []byte(strings.Join(newLines, "\n")), 0o600); err != nil {
+		return err
+	}
+
+	fmt.Printf("✓ Updated main go.mod\n")
+	return nil
+}
+
+func extractGeneratorPackage(line string) string {
+	if !strings.Contains(line, "github.com/external-secrets/external-secrets/generators/v1/") {
+		return ""
+	}
+	// Extract package name from line like:
+	// "\tgithub.com/external-secrets/external-secrets/generators/v1/uuid => ./generators/v1/uuid"
+	parts := strings.Split(line, "/")
+	if len(parts) == 0 {
+		return ""
+	}
+	lastPart := parts[len(parts)-1]
+	// Remove everything after space (the => part)
+	if idx := strings.Index(lastPart, " "); idx != -1 {
+		lastPart = lastPart[:idx]
+	}
+	return strings.TrimSpace(lastPart)
+}
+
+func updateResolverFile(rootDir string, cfg Config) error {
+	resolverFile := filepath.Join(rootDir, "runtime", "esutils", "resolvers", "generator.go")
+
+	data, err := os.ReadFile(filepath.Clean(resolverFile))
+	if err != nil {
+		return err
+	}
+
+	content := string(data)
+
+	// Check if already exists
+	if strings.Contains(content, fmt.Sprintf("GeneratorKind%s", cfg.GeneratorName)) {
+		fmt.Printf("⚠ Generator already exists in resolver file\n")
+		return nil
+	}
+
+	// Create the case statement to add
+	caseBlock := fmt.Sprintf(`	case genv1alpha1.GeneratorKind%s:
+		if gen.Spec.Generator.%sSpec == nil {
+			return nil, fmt.Errorf("when kind is %%s, %sSpec must be set", gen.Spec.Kind)
+		}
+		return &genv1alpha1.%s{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.%sKind,
+			},
+			Spec: *gen.Spec.Generator.%sSpec,
+		}, nil`,
+		cfg.GeneratorName, cfg.GeneratorName, cfg.GeneratorName,
+		cfg.GeneratorName, cfg.GeneratorName, cfg.GeneratorName)
+
+	lines := strings.Split(content, "\n")
+	newLines := make([]string, 0, len(lines)+10)
+	added := false
+
+	for _, line := range lines {
+		// Find the default case and add before it
+		if !added && strings.TrimSpace(line) == "default:" {
+			newLines = append(newLines, caseBlock)
+			added = true
+		}
+
+		newLines = append(newLines, line)
+	}
+
+	if !added {
+		return fmt.Errorf("could not find default case in resolver file")
+	}
+
+	if err := os.WriteFile(filepath.Clean(resolverFile), []byte(strings.Join(newLines, "\n")), 0o600); err != nil {
+		return err
+	}
+
+	fmt.Printf("✓ Updated resolver file: %s\n", resolverFile)
+	return nil
+}
+
+func updateRegisterKindFile(rootDir string, cfg Config) error {
+	registerFile := filepath.Join(rootDir, "apis", "generators", "v1alpha1", "register.go")
+
+	data, err := os.ReadFile(filepath.Clean(registerFile))
+	if err != nil {
+		return err
+	}
+
+	content := string(data)
+
+	// Check if already exists
+	if strings.Contains(content, fmt.Sprintf("%sKind", cfg.GeneratorName)) {
+		fmt.Printf("⚠ Generator kind already exists in register.go\n")
+		return nil
+	}
+
+	lines := strings.Split(content, "\n")
+	newLines := make([]string, 0, len(lines)+4)
+	kindAdded := false
+	schemeAdded := false
+
+	for i, line := range lines {
+		newLines = append(newLines, line)
+
+		// Add Kind constant before closing paren of var block
+		if !kindAdded && strings.Contains(line, "Kind = reflect.TypeOf(") && strings.Contains(line, "{}).Name()") {
+			// Look ahead to see if next line is closing paren
+			if i+1 < len(lines) && strings.TrimSpace(lines[i+1]) == ")" {
+				// Add blank line and then the new kind
+				newLines = append(newLines, "")
+				kindComment := fmt.Sprintf("\t// %sKind is the kind name for %s resource.", cfg.GeneratorName, cfg.GeneratorName)
+				newLines = append(newLines, kindComment)
+				kindLine := fmt.Sprintf("\t%sKind = reflect.TypeOf(%s{}).Name()", cfg.GeneratorName, cfg.GeneratorName)
+				newLines = append(newLines, kindLine)
+				kindAdded = true
+			}
+		}
+
+		// Add SchemeBuilder.Register call before closing brace of init function
+		if !schemeAdded && strings.Contains(line, "SchemeBuilder.Register(&") && strings.Contains(line, "List{})") {
+			// Look ahead to see if next line is closing brace
+			if i+1 < len(lines) && strings.TrimSpace(lines[i+1]) == "}" {
+				registerLine := fmt.Sprintf("\tSchemeBuilder.Register(&%s{}, &%sList{})", cfg.GeneratorName, cfg.GeneratorName)
+				newLines = append(newLines, registerLine)
+				schemeAdded = true
+			}
+		}
+	}
+
+	if !kindAdded || !schemeAdded {
+		fmt.Printf("⚠ Warning: Could not fully update register.go. Please manually add:\n")
+		if !kindAdded {
+			fmt.Printf("   1. Add Kind constant: %sKind = reflect.TypeOf(%s{}).Name()\n", cfg.GeneratorName, cfg.GeneratorName)
+		}
+		if !schemeAdded {
+			fmt.Printf("   2. Add SchemeBuilder registration: SchemeBuilder.Register(&%s{}, &%sList{})\n", cfg.GeneratorName, cfg.GeneratorName)
+		}
+	} else {
+		if err := os.WriteFile(filepath.Clean(registerFile), []byte(strings.Join(newLines, "\n")), 0o600); err != nil {
+			return err
+		}
+		fmt.Printf("✓ Updated register.go\n")
+	}
+
+	return nil
+}

+ 49 - 0
cmd/esoctl/generator/templates/crd.go.tmpl

@@ -0,0 +1,49 @@
+/*
+Copyright © 2025 ESO Maintainer Team
+
+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
+
+    https://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 (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// {{.GeneratorName}}Spec controls the behavior of the {{.PackageName}} generator.
+type {{.GeneratorName}}Spec struct {
+	// TODO: Add your spec fields here
+	// Example: Length int `json:"length,omitempty"`
+}
+
+// {{.GeneratorName}} {{.Description}}.
+// +kubebuilder:object:root=true
+// +kubebuilder:storageversion
+// +kubebuilder:subresource:status
+// +kubebuilder:metadata:labels="external-secrets.io/component=controller"
+// +kubebuilder:resource:scope=Namespaced,categories={external-secrets, external-secrets-generators}
+type {{.GeneratorName}} struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec {{.GeneratorName}}Spec `json:"spec,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// {{.GeneratorName}}List contains a list of {{.GeneratorName}} resources.
+type {{.GeneratorName}}List struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []{{.GeneratorName}} `json:"items"`
+}

+ 16 - 0
cmd/esoctl/generator/templates/go.mod.tmpl

@@ -0,0 +1,16 @@
+module github.com/external-secrets/external-secrets/generators/v1/{{.PackageName}}
+
+go 1.25.3
+
+require (
+	github.com/external-secrets/external-secrets/apis v0.0.0
+	github.com/stretchr/testify v1.11.1
+	k8s.io/apiextensions-apiserver v0.34.1
+	sigs.k8s.io/controller-runtime v0.22.3
+	sigs.k8s.io/yaml v1.6.0
+)
+
+replace (
+	github.com/external-secrets/external-secrets/apis => ../../../apis
+	github.com/external-secrets/external-secrets/runtime => ../../../runtime
+)

+ 74 - 0
cmd/esoctl/generator/templates/implementation.go.tmpl

@@ -0,0 +1,74 @@
+/*
+Copyright © 2025 ESO Maintainer Team
+
+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
+
+    https://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 {{.PackageName}} provides functionality for {{.Description}}.
+package {{.PackageName}}
+
+import (
+	"context"
+	"fmt"
+
+	apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/yaml"
+
+	genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
+)
+
+// Generator implements {{.Description}} functionality.
+type Generator struct{}
+
+// Generate creates the output for this generator.
+func (g *Generator) Generate(_ context.Context, jsonSpec *apiextensions.JSON, _ client.Client, _ string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
+	if jsonSpec == nil {
+		return nil, nil, fmt.Errorf("no spec provided")
+	}
+
+	spec, err := parseSpec(jsonSpec.Raw)
+	if err != nil {
+		return nil, nil, fmt.Errorf("failed to parse spec: %w", err)
+	}
+
+	// TODO: Implement your generator logic here
+	_ = spec
+
+	// Example return - replace with your actual implementation
+	return map[string][]byte{
+		"result": []byte("TODO: implement {{.PackageName}} generator"),
+	}, nil, nil
+}
+
+// Cleanup performs any necessary cleanup after generation.
+func (g *Generator) Cleanup(_ context.Context, _ *apiextensions.JSON, _ genv1alpha1.GeneratorProviderState, _ client.Client, _ string) error {
+	// TODO: Implement cleanup if needed
+	return nil
+}
+
+func parseSpec(data []byte) (*genv1alpha1.{{.GeneratorName}}, error) {
+	var spec genv1alpha1.{{.GeneratorName}}
+	err := yaml.Unmarshal(data, &spec)
+	return &spec, err
+}
+
+// NewGenerator creates a new Generator instance.
+func NewGenerator() genv1alpha1.Generator {
+	return &Generator{}
+}
+
+// Kind returns the generator kind.
+func Kind() string {
+	return string(genv1alpha1.{{.GeneratorKind}})
+}

+ 68 - 0
cmd/esoctl/generator/templates/test.go.tmpl

@@ -0,0 +1,68 @@
+/*
+Copyright © 2025 ESO Maintainer Team
+
+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
+
+    https://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 {{.PackageName}}
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+)
+
+func TestGenerate(t *testing.T) {
+	g := &Generator{}
+
+	tests := []struct {
+		name      string
+		jsonSpec  *apiextensions.JSON
+		wantErr   bool
+		errString string
+	}{
+		{
+			name:      "nil spec",
+			jsonSpec:  nil,
+			wantErr:   true,
+			errString: "no spec provided",
+		},
+		// TODO: Add more test cases
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result, state, err := g.Generate(context.Background(), tt.jsonSpec, nil, "default")
+			
+			if tt.wantErr {
+				assert.Error(t, err)
+				if tt.errString != "" {
+					assert.Contains(t, err.Error(), tt.errString)
+				}
+				assert.Nil(t, result)
+				assert.Nil(t, state)
+			} else {
+				assert.NoError(t, err)
+				assert.NotNil(t, result)
+			}
+		})
+	}
+}
+
+func TestKind(t *testing.T) {
+	kind := Kind()
+	assert.NotEmpty(t, kind)
+	assert.Equal(t, "{{.GeneratorName}}", kind)
+}

+ 44 - 0
cmd/esoctl/generator/utils.go

@@ -0,0 +1,44 @@
+/*
+Copyright © 2025 ESO Maintainer Team
+
+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
+
+	https://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 generator
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+// FindRootDir finds the root directory of the external-secrets repository.
+func FindRootDir(startDir string) string {
+	dir := startDir
+	for {
+		// Check if go.mod exists and contains external-secrets
+		goModPath := filepath.Join(dir, "go.mod")
+		if data, err := os.ReadFile(filepath.Clean(goModPath)); err == nil {
+			if strings.Contains(string(data), "module github.com/external-secrets/external-secrets") {
+				return dir
+			}
+		}
+
+		parent := filepath.Dir(dir)
+		if parent == dir {
+			break
+		}
+		dir = parent
+	}
+	return ""
+}

+ 4 - 0
cmd/esoctl/root.go

@@ -18,6 +18,10 @@ package main
 
 import "github.com/spf13/cobra"
 
+func init() {
+	rootCmd.AddCommand(bootstrapCmd)
+}
+
 var rootCmd = &cobra.Command{
 	Use:   "esoctl",
 	Short: "operations for external-secrets-operator",

+ 165 - 1
docs/guides/using-esoctl-tool.md

@@ -1,6 +1,11 @@
 # Using the esoctl tool
 
-The tool can be found under `cmd/esoctl`. The `template` command can be used to test templates for `PushSecret` and `ExternalSecret`.
+The tool can be found under `cmd/esoctl`.
+
+
+## Debugging templates
+
+ The `template` command can be used to test templates for `PushSecret` and `ExternalSecret`.
 
 To run render simply execute `make build` in the `cmd/esoctl` folder. This will result in a binary under `cmd/esoctl/bin`.
 
@@ -56,3 +61,162 @@ bin/esoctl template --source-templated-object template-test/push-secret.yaml \
   --template-from-config-map template-test/template-config-map.yaml \
   --template-from-secret template-test/template-secret.yaml
 ```
+
+## Bootstrapping generator code
+
+The `bootstrap generator` command can be used to create a new generator.
+
+When running it, it will automatically:
+
+- Bootstrap a new generator CRD
+- Bootstrap a new generator implementation
+- Update the register file with the new generator
+- Update Cluster Generators to include the new generator
+- Update needed dependencies  (go.mod, resolver file, etc)
+
+To run, simply execute:
+```
+bin/esoctl bootstrap generator --name GeneratorName --description "A description of this generator" --package generatorname
+```
+
+### Example
+```
+bin/esoctl bootstrap generator --name MyAwesomeGenerator --description "An awesome generator I want to add to ESO :)"
+
+✓ Created CRD: /home/gusfcarvalho/Documents/repos/external-secrets/apis/generators/v1alpha1/types_myawesomegenerator.go
+✓ Created implementation: /home/gusfcarvalho/Documents/repos/external-secrets/generators/v1/myawesomegenerator/myawesomegenerator.go
+✓ Created test file: /home/gusfcarvalho/Documents/repos/external-secrets/generators/v1/myawesomegenerator/myawesomegenerator_test.go
+✓ Created go.mod: /home/gusfcarvalho/Documents/repos/external-secrets/generators/v1/myawesomegenerator/go.mod
+✓ Created go.sum: /home/gusfcarvalho/Documents/repos/external-secrets/generators/v1/myawesomegenerator/go.sum
+✓ Updated register file: /home/gusfcarvalho/Documents/repos/external-secrets/pkg/register/generators.go
+✓ Updated types_cluster.go
+✓ Updated main go.mod
+✓ Updated resolver file: /home/gusfcarvalho/Documents/repos/external-secrets/runtime/esutils/resolvers/generator.go
+✓ Updated register.go
+✓ Successfully bootstrapped generator: MyAwesomeGenerator
+
+Next steps:
+1. Review and customize: apis/generators/v1alpha1/types_myawesomegenerator.go
+2. Implement the generator logic in: generators/v1/myawesomegenerator/myawesomegenerator.go
+3. Run: go mod tidy
+4. Run: make generate
+5. Run: make manifests
+6. Add tests for your generator
+```
+
+You should also expect the following `git diff` with specific changes:
+
+```diff
+diff --git a/apis/generators/v1alpha1/register.go b/apis/generators/v1alpha1/register.go
+index 16c05154b..9538bcc57 100644
+--- a/apis/generators/v1alpha1/register.go
++++ b/apis/generators/v1alpha1/register.go
+@@ -73,6 +73,9 @@ var (
+        ClusterGeneratorKind = reflect.TypeOf(ClusterGenerator{}).Name()
+        // CloudsmithAccessTokenKind is the kind name for CloudsmithAccessToken resource.
+        CloudsmithAccessTokenKind = reflect.TypeOf(CloudsmithAccessToken{}).Name()
++
++       // MyAwesomeGeneratorKind is the kind name for MyAwesomeGenerator resource.
++       MyAwesomeGeneratorKind = reflect.TypeOf(MyAwesomeGenerator{}).Name()
+ )
+ 
+ func init() {
+@@ -109,4 +112,5 @@ func init() {
+        SchemeBuilder.Register(&Webhook{}, &WebhookList{})
+        SchemeBuilder.Register(&Grafana{}, &GrafanaList{})
+        SchemeBuilder.Register(&MFA{}, &MFAList{})
++       SchemeBuilder.Register(&MyAwesomeGenerator{}, &MyAwesomeGeneratorList{})
+ }
+diff --git a/apis/generators/v1alpha1/types_cluster.go b/apis/generators/v1alpha1/types_cluster.go
+index e212dab76..0245e8f1c 100644
+--- a/apis/generators/v1alpha1/types_cluster.go
++++ b/apis/generators/v1alpha1/types_cluster.go
+@@ -30,7 +30,7 @@ type ClusterGeneratorSpec struct {
+ }
+ 
+ // GeneratorKind represents a kind of generator.
+-// +kubebuilder:validation:Enum=ACRAccessToken;CloudsmithAccessToken;ECRAuthorizationToken;Fake;GCRAccessToken;GithubAccessToken;QuayAccessToken;Password;SSHKey;STSSessionToken;UUID;VaultDynamicSecret;Webhook;Grafana
++// +kubebuilder:validation:Enum=ACRAccessToken;CloudsmithAccessToken;ECRAuthorizationToken;Fake;GCRAccessToken;GithubAccessToken;QuayAccessToken;Password;SSHKey;STSSessionToken;UUID;VaultDynamicSecret;Webhook;Grafana;MyAwesomeGenerator
+ type GeneratorKind string
+ 
+ const (
+@@ -64,6 +64,8 @@ const (
+        GeneratorKindMFA GeneratorKind = "MFA"
+        // GeneratorKindCloudsmithAccessToken represents a Cloudsmith access token generator.
+        GeneratorKindCloudsmithAccessToken GeneratorKind = "CloudsmithAccessToken"
++       // GeneratorKindMyAwesomeGenerator represents a myawesomegenerator generator.
++       GeneratorKindMyAwesomeGenerator GeneratorKind = "MyAwesomeGenerator"
+ )
+ 
+ // GeneratorSpec defines the configuration for various supported generator types.
+@@ -85,6 +87,7 @@ type GeneratorSpec struct {
+        WebhookSpec               *WebhookSpec               `json:"webhookSpec,omitempty"`
+        GrafanaSpec               *GrafanaSpec               `json:"grafanaSpec,omitempty"`
+        MFASpec                   *MFASpec                   `json:"mfaSpec,omitempty"`
++       MyAwesomeGeneratorSpec             *MyAwesomeGeneratorSpec             `json:"myawesomegeneratorSpec,omitempty"`
+ }
+ 
+ // ClusterGenerator represents a cluster-wide generator which can be referenced as part of `generatorRef` fields.
+diff --git a/go.mod b/go.mod
+index ff95a9558..c73ecb0c7 100644
+--- a/go.mod
++++ b/go.mod
+@@ -14,6 +14,7 @@ replace (
+        github.com/external-secrets/external-secrets/generators/v1/github => ./generators/v1/github
+        github.com/external-secrets/external-secrets/generators/v1/grafana => ./generators/v1/grafana
+        github.com/external-secrets/external-secrets/generators/v1/mfa => ./generators/v1/mfa
++       github.com/external-secrets/external-secrets/generators/v1/myawesomegenerator => ./generators/v1/myawesomegenerator
+        github.com/external-secrets/external-secrets/generators/v1/password => ./generators/v1/password
+        github.com/external-secrets/external-secrets/generators/v1/quay => ./generators/v1/quay
+        github.com/external-secrets/external-secrets/generators/v1/sshkey => ./generators/v1/sshkey
+diff --git a/pkg/register/generators.go b/pkg/register/generators.go
+index dd9ad55fb..6aafd4089 100644
+--- a/pkg/register/generators.go
++++ b/pkg/register/generators.go
+@@ -34,6 +34,7 @@ import (
+        uuid "github.com/external-secrets/external-secrets/generators/v1/uuid"
+        vaultgen "github.com/external-secrets/external-secrets/generators/v1/vault"
+        webhookgen "github.com/external-secrets/external-secrets/generators/v1/webhook"
++       myawesomegenerator "github.com/external-secrets/external-secrets/generators/v1/myawesomegenerator"
+ )
+ 
+ func init() {
+@@ -53,4 +54,5 @@ func init() {
+        genv1alpha1.Register(uuid.Kind(), uuid.NewGenerator())
+        genv1alpha1.Register(vaultgen.Kind(), vaultgen.NewGenerator())
+        genv1alpha1.Register(webhookgen.Kind(), webhookgen.NewGenerator())
++       genv1alpha1.Register(myawesomegenerator.Kind(), myawesomegenerator.NewGenerator())
+ }
+diff --git a/runtime/esutils/resolvers/generator.go b/runtime/esutils/resolvers/generator.go
+index 66f4b4037..938ccd6cd 100644
+--- a/runtime/esutils/resolvers/generator.go
++++ b/runtime/esutils/resolvers/generator.go
+@@ -302,6 +302,17 @@ func clusterGeneratorToVirtual(gen *genv1alpha1.ClusterGenerator) (client.Object
+                        },
+                        Spec: *gen.Spec.Generator.MFASpec,
+                }, nil
++       case genv1alpha1.GeneratorKindMyAwesomeGenerator:
++               if gen.Spec.Generator.MyAwesomeGeneratorSpec == nil {
++                       return nil, fmt.Errorf("when kind is %s, MyAwesomeGeneratorSpec must be set", gen.Spec.Kind)
++               }
++               return &genv1alpha1.MyAwesomeGenerator{
++                       TypeMeta: metav1.TypeMeta{
++                               APIVersion: genv1alpha1.SchemeGroupVersion.String(),
++                               Kind:       genv1alpha1.MyAwesomeGeneratorKind,
++                       },
++                       Spec: *gen.Spec.Generator.MyAwesomeGeneratorSpec,
++               }, nil
+        default:
+                return nil, fmt.Errorf("unknown kind %s", gen.Spec.Kind)
+        }
+```
+
+### flags
+#### name
+Defines the generator name. Must be `PascalCase`.
+
+#### description
+Defines the generator description (added as a golang comment)
+
+#### package (optional)
+Defines the package name for the generator. Must be `snake_case`. defaults to lowercase of `name`