Просмотр исходного кода

feat: add provider generator tooling

Moritz Johner 1 месяц назад
Родитель
Сommit
5376476189

+ 256 - 0
providers/v2/hack/README.md

@@ -0,0 +1,256 @@
+# Provider Main Generator
+
+This directory contains the code generation tooling for v2 provider `main.go` and `Dockerfile` files.
+
+## Overview
+
+The generator reduces boilerplate and maintenance burden by centralizing the common provider startup logic (flags, gRPC server setup, health checks, graceful shutdown, etc.) while allowing provider-specific configuration through YAML files.
+
+## Directory Structure
+
+```
+providers/v2/hack/
+├── generate-provider-main.go      # Generator tool
+├── schema/
+│   └── provider-config.schema.json  # JSON schema for provider.yaml validation
+├── templates/
+│   ├── main.go.tmpl               # Template for main.go
+│   └── Dockerfile.tmpl            # Template for Dockerfile
+└── README.md                      # This file
+```
+
+## Usage
+
+### Generate Provider Files
+
+From the repository root:
+
+```bash
+make generate-providers
+```
+
+This will:
+1. Find all `provider.yaml` files in `providers/v2/`
+2. Validate each against the JSON schema
+3. Generate `main.go` and `Dockerfile` for each provider
+4. Format the generated Go code with `goimports`
+
+### Verify Generated Files Are Up-to-Date
+
+```bash
+make verify-providers
+```
+
+This checks if any generated files are out of sync with their configuration.
+
+## Adding a New Provider
+
+To add a new v2 provider:
+
+1. **Create the provider directory structure:**
+   ```
+   providers/v2/myprovider/
+   ├── provider.yaml     # Configuration (required)
+   ├── config.go         # Spec mapper logic (required)
+   ├── store/            # v1 store implementation
+   └── generator/        # v1 generator implementation (optional)
+   ```
+
+2. **Create `provider.yaml`:**
+
+   ```yaml
+   provider:
+     name: myprovider
+     displayName: "My Provider"
+     v2Package: "github.com/external-secrets/external-secrets/apis/provider/myprovider/v2alpha1"
+
+   stores:
+     - gvk:
+         group: "provider.external-secrets.io"
+         version: "v2alpha1"
+         kind: "MyProvider"
+       v1Provider: "github.com/external-secrets/external-secrets/providers/v1/myprovider"
+       v1ProviderFunc: "NewProvider"
+
+   # Optional: if provider includes generators
+   generators:
+     - gvk:
+         group: "generators.external-secrets.io"
+         version: "v1alpha1"
+         kind: "MyGenerator"
+       v1Generator: "github.com/external-secrets/external-secrets/providers/v2/myprovider/generator"
+       v1GeneratorFunc: "NewGenerator"
+
+   configPackage: "."
+   ```
+
+3. **Create `config.go` with GetSpecMapper function:**
+
+   ```go
+   package main
+
+   import (
+       "context"
+       "sigs.k8s.io/controller-runtime/pkg/client"
+       v1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+       myproviderv2alpha1 "github.com/external-secrets/external-secrets/apis/provider/myprovider/v2alpha1"
+       pb "github.com/external-secrets/external-secrets/proto/provider"
+   )
+
+   func GetSpecMapper(kubeClient client.Client) func(*pb.ProviderReference) (*v1.SecretStoreSpec, error) {
+       return func(ref *pb.ProviderReference) (*v1.SecretStoreSpec, error) {
+           var provider myproviderv2alpha1.MyProvider
+           err := kubeClient.Get(context.Background(), client.ObjectKey{
+               Namespace: ref.Namespace,
+               Name:      ref.Name,
+           }, &provider)
+           if err != nil {
+               return nil, err
+           }
+           return &v1.SecretStoreSpec{
+               Provider: &v1.SecretStoreProvider{
+                   MyProvider: &provider.Spec,
+               },
+           }, nil
+       }
+   }
+   ```
+
+4. **Generate the files:**
+   ```bash
+   make generate-providers
+   ```
+
+5. **Test the provider compiles:**
+   ```bash
+   cd providers/v2/myprovider && go build
+   ```
+
+## Provider Configuration Schema
+
+### Required Fields
+
+- `provider.name`: Provider name (lowercase, alphanumeric with hyphens)
+- `provider.displayName`: Human-readable provider name
+
+### Optional Fields
+
+- `provider.v2Package`: Go import path for v2alpha1 API (required if using stores)
+- `stores`: Array of store implementations
+- `generators`: Array of generator implementations
+- `configPackage`: Relative import path for config.go (default: ".")
+
+### Store Configuration
+
+```yaml
+stores:
+  - gvk:
+      group: "provider.external-secrets.io"
+      version: "v2alpha1"
+      kind: "MyKind"
+    v1Provider: "github.com/org/repo/providers/v1/myprovider"
+    v1ProviderFunc: "NewProvider"
+```
+
+### Generator Configuration
+
+```yaml
+generators:
+  - gvk:
+      group: "generators.external-secrets.io"
+      version: "v1alpha1"
+      kind: "MyGenerator"
+    v1Generator: "github.com/org/repo/providers/v2/myprovider/generator"
+    v1GeneratorFunc: "NewMyGenerator"
+```
+
+## Examples
+
+### Provider with Single Store (Kubernetes)
+
+```yaml
+provider:
+  name: kubernetes
+  displayName: "Kubernetes Provider"
+  v2Package: "github.com/external-secrets/external-secrets/apis/provider/kubernetes/v2alpha1"
+
+stores:
+  - gvk:
+      group: "provider.external-secrets.io"
+      version: "v2alpha1"
+      kind: "Kubernetes"
+    v1Provider: "github.com/external-secrets/external-secrets/providers/v1/kubernetes"
+    v1ProviderFunc: "NewProvider"
+
+configPackage: "."
+```
+
+### Provider with Store and Generators (AWS)
+
+```yaml
+provider:
+  name: aws
+  displayName: "AWS Provider"
+  v2Package: "github.com/external-secrets/external-secrets/apis/provider/aws/v2alpha1"
+
+stores:
+  - gvk:
+      group: "provider.external-secrets.io"
+      version: "v2alpha1"
+      kind: "SecretsManager"
+    v1Provider: "github.com/external-secrets/external-secrets/providers/v2/aws/store"
+    v1ProviderFunc: "NewProvider"
+
+generators:
+  - gvk:
+      group: "generators.external-secrets.io"
+      version: "v1alpha1"
+      kind: "ECRAuthorizationToken"
+    v1Generator: "github.com/external-secrets/external-secrets/providers/v2/aws/generator"
+    v1GeneratorFunc: "NewECRGenerator"
+  - gvk:
+      group: "generators.external-secrets.io"
+      version: "v1alpha1"
+      kind: "STSSessionToken"
+    v1Generator: "github.com/external-secrets/external-secrets/providers/v2/aws/generator"
+    v1GeneratorFunc: "NewSTSGenerator"
+
+configPackage: "."
+```
+
+## Troubleshooting
+
+### Validation Errors
+
+If you see schema validation errors:
+1. Check that your `provider.yaml` follows the schema
+2. Ensure all required fields are present
+3. Verify that at least one of `stores` or `generators` is defined
+
+### Compilation Errors
+
+If generated code doesn't compile:
+1. Verify import paths in `provider.yaml` are correct
+2. Check that `GetSpecMapper` function signature matches expected format
+3. Ensure v1 provider/generator packages export the specified constructor functions
+
+### Import Conflicts
+
+The generator automatically handles import aliases. If you have multiple stores or generators from the same package, they will share the same import alias.
+
+## Development
+
+To modify the generator:
+
+1. Edit the generator logic in `generate-provider-main.go`
+2. Update templates in `templates/`
+3. Update schema in `schema/provider-config.schema.json`
+4. Regenerate all providers to test: `make generate-providers`
+5. Verify nothing broke: `make verify-providers`
+
+## Future Enhancements
+
+- Support for custom CLI flags (deferred)
+- Support for custom middleware
+- Automatic detection of v1 providers to generate configs
+

+ 401 - 0
providers/v2/hack/generate-provider-main.go

@@ -0,0 +1,401 @@
+/*
+Copyright © The ESO 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
+
+    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 provides a tool to generate provider main.go files from provider configuration.
+package main
+
+import (
+	"bytes"
+	"embed"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"log"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"text/template"
+
+	"github.com/xeipuuv/gojsonschema"
+	"gopkg.in/yaml.v3"
+)
+
+//go:embed schema/provider-config.schema.json templates/*.tmpl
+var embeddedFS embed.FS
+
+type providerMetadata struct {
+	Name        string `yaml:"name"        json:"name"`
+	DisplayName string `yaml:"displayName" json:"displayName"`
+	V2Package   string `yaml:"v2Package"   json:"v2Package"`
+}
+
+type gvkConfig struct {
+	Group   string `yaml:"group"   json:"group"`
+	Version string `yaml:"version" json:"version"`
+	Kind    string `yaml:"kind"    json:"kind"`
+}
+
+type storeConfig struct {
+	GVK            gvkConfig `yaml:"gvk"            json:"gvk"`
+	V1Provider     string    `yaml:"v1Provider"     json:"v1Provider"`
+	V1ProviderFunc string    `yaml:"v1ProviderFunc" json:"v1ProviderFunc"`
+}
+
+type generatorConfig struct {
+	GVK             gvkConfig `yaml:"gvk"             json:"gvk"`
+	V1Generator     string    `yaml:"v1Generator"     json:"v1Generator"`
+	V1GeneratorFunc string    `yaml:"v1GeneratorFunc" json:"v1GeneratorFunc"`
+}
+
+// ProviderConfig represents the structure of provider.yaml.
+type ProviderConfig struct {
+	Provider      providerMetadata  `yaml:"provider"      json:"provider"`
+	Stores        []storeConfig     `yaml:"stores"        json:"stores"`
+	Generators    []generatorConfig `yaml:"generators"    json:"generators"`
+	ConfigPackage string            `yaml:"configPackage" json:"configPackage"`
+}
+
+// ImportInfo tracks package imports with aliases.
+type ImportInfo struct {
+	Path  string
+	Alias string
+}
+
+// TemplateData contains all data needed for template rendering.
+type TemplateData struct {
+	Provider               ProviderConfig
+	HasStores              bool
+	HasGenerators          bool
+	UniqueStoreImports     []ImportInfo
+	UniqueGeneratorImports []ImportInfo
+	Stores                 []StoreInfo
+	Generators             []GeneratorInfo
+}
+
+type StoreInfo struct {
+	GVK struct {
+		Group   string
+		Version string
+		Kind    string
+	}
+	V1Provider     string
+	V1ProviderFunc string
+	ImportAlias    string
+}
+
+type GeneratorInfo struct {
+	GVK struct {
+		Group   string
+		Version string
+		Kind    string
+	}
+	V1Generator     string
+	V1GeneratorFunc string
+	ImportAlias     string
+}
+
+var (
+	providersDir = flag.String("providers-dir", "providers/v2", "Base directory for v2 providers")
+	dryRun       = flag.Bool("dry-run", false, "Print what would be generated without writing files")
+	verbose      = flag.Bool("verbose", false, "Enable verbose output")
+)
+
+func main() {
+	flag.Parse()
+
+	log.SetFlags(0)
+
+	// Load the JSON schema
+	schemaBytes, err := embeddedFS.ReadFile("schema/provider-config.schema.json")
+	if err != nil {
+		log.Fatalf("Failed to read schema: %v", err)
+	}
+
+	schemaLoader := gojsonschema.NewBytesLoader(schemaBytes)
+
+	// Find all provider.yaml files
+	providerConfigs, err := findProviderConfigs(*providersDir)
+	if err != nil {
+		log.Fatalf("Failed to find provider configs: %v", err)
+	}
+
+	if len(providerConfigs) == 0 {
+		log.Printf("No provider.yaml files found in %s", *providersDir)
+		return
+	}
+
+	log.Printf("Found %d provider configuration(s)", len(providerConfigs))
+
+	// Load templates
+	mainTemplate, err := loadTemplate("templates/main.go.tmpl")
+	if err != nil {
+		log.Fatalf("Failed to load main.go template: %v", err)
+	}
+
+	dockerfileTemplate, err := loadTemplate("templates/Dockerfile.tmpl")
+	if err != nil {
+		log.Fatalf("Failed to load Dockerfile template: %v", err)
+	}
+
+	// Process each provider
+	var generatedCount int
+	for _, configPath := range providerConfigs {
+		providerDir := filepath.Dir(configPath)
+		log.Printf("\nProcessing: %s", configPath)
+
+		// Load and validate config
+		config, err := loadAndValidateConfig(configPath, schemaLoader)
+		if err != nil {
+			log.Fatalf("Failed to load/validate config %s: %v", configPath, err)
+		}
+
+		if *verbose {
+			log.Printf("  Provider: %s (%s)", config.Provider.Name, config.Provider.DisplayName)
+			log.Printf("  Stores: %d, Generators: %d", len(config.Stores), len(config.Generators))
+		}
+
+		// Prepare template data
+		templateData := prepareTemplateData(config)
+
+		// Generate main.go
+		mainContent, err := executeTemplate(mainTemplate, templateData)
+		if err != nil {
+			log.Fatalf("Failed to generate main.go for %s: %v", config.Provider.Name, err)
+		}
+
+		// Format with goimports/gofmt
+		formattedMain, err := formatGoCode(mainContent)
+		if err != nil {
+			log.Printf("Warning: Failed to format main.go for %s: %v", config.Provider.Name, err)
+			formattedMain = mainContent // Use unformatted if formatting fails
+		}
+
+		mainPath := filepath.Join(providerDir, "main.go")
+		if *dryRun {
+			log.Printf("  Would write: %s (%d bytes)", mainPath, len(formattedMain))
+		} else {
+			if err := os.WriteFile(mainPath, formattedMain, 0600); err != nil {
+				log.Fatalf("Failed to write main.go: %v", err)
+			}
+			log.Printf("  ✓ Generated: main.go")
+		}
+
+		// Generate Dockerfile
+		dockerContent, err := executeTemplate(dockerfileTemplate, templateData)
+		if err != nil {
+			log.Fatalf("Failed to generate Dockerfile for %s: %v", config.Provider.Name, err)
+		}
+
+		dockerPath := filepath.Join(providerDir, "Dockerfile")
+		if *dryRun {
+			log.Printf("  Would write: %s (%d bytes)", dockerPath, len(dockerContent))
+		} else {
+			if err := os.WriteFile(dockerPath, dockerContent, 0600); err != nil {
+				log.Fatalf("Failed to write Dockerfile: %v", err)
+			}
+			log.Printf("  ✓ Generated: Dockerfile")
+		}
+
+		generatedCount++
+	}
+
+	log.Printf("\n✓ Successfully generated files for %d provider(s)", generatedCount)
+}
+
+func findProviderConfigs(baseDir string) ([]string, error) {
+	var configs []string
+
+	err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		if !info.IsDir() && info.Name() == "provider.yaml" {
+			configs = append(configs, path)
+		}
+		return nil
+	})
+
+	return configs, err
+}
+
+func loadAndValidateConfig(configPath string, schemaLoader gojsonschema.JSONLoader) (*ProviderConfig, error) {
+	// Read YAML file
+	//nolint:gosec // configPath comes from controlled provider config discovery under providers-dir.
+	yamlBytes, err := os.ReadFile(configPath)
+	if err != nil {
+		return nil, fmt.Errorf("read file: %w", err)
+	}
+
+	// Parse YAML into config struct
+	var config ProviderConfig
+	if err := yaml.Unmarshal(yamlBytes, &config); err != nil {
+		return nil, fmt.Errorf("parse YAML: %w", err)
+	}
+
+	// Convert to JSON for schema validation
+	jsonBytes, err := json.Marshal(config)
+	if err != nil {
+		return nil, fmt.Errorf("convert to JSON: %w", err)
+	}
+
+	// Validate against schema
+	documentLoader := gojsonschema.NewBytesLoader(jsonBytes)
+	result, err := gojsonschema.Validate(schemaLoader, documentLoader)
+	if err != nil {
+		return nil, fmt.Errorf("validate schema: %w", err)
+	}
+
+	if !result.Valid() {
+		var errMsgs []string
+		for _, desc := range result.Errors() {
+			errMsgs = append(errMsgs, fmt.Sprintf("  - %s", desc))
+		}
+		return nil, fmt.Errorf("schema validation failed:\n%s", strings.Join(errMsgs, "\n"))
+	}
+
+	return &config, nil
+}
+
+func loadTemplate(name string) (*template.Template, error) {
+	content, err := embeddedFS.ReadFile(name)
+	if err != nil {
+		return nil, err
+	}
+
+	tmpl, err := template.New(name).Parse(string(content))
+	if err != nil {
+		return nil, err
+	}
+
+	return tmpl, nil
+}
+
+func prepareTemplateData(config *ProviderConfig) *TemplateData {
+	data := &TemplateData{
+		Provider:      *config,
+		HasStores:     len(config.Stores) > 0,
+		HasGenerators: len(config.Generators) > 0,
+	}
+
+	// Collect unique imports for stores
+	storeImports := make(map[string]string) // path -> alias
+	seenStoreImports := make(map[string]int)
+	for _, store := range config.Stores {
+		alias := generateImportAlias(store.V1Provider, seenStoreImports)
+		storeImports[store.V1Provider] = alias
+
+		storeInfo := StoreInfo{
+			V1Provider:     store.V1Provider,
+			V1ProviderFunc: store.V1ProviderFunc,
+			ImportAlias:    alias,
+		}
+		storeInfo.GVK.Group = store.GVK.Group
+		storeInfo.GVK.Version = store.GVK.Version
+		storeInfo.GVK.Kind = store.GVK.Kind
+
+		data.Stores = append(data.Stores, storeInfo)
+	}
+
+	for path, alias := range storeImports {
+		data.UniqueStoreImports = append(data.UniqueStoreImports, ImportInfo{
+			Path:  path,
+			Alias: alias,
+		})
+	}
+
+	// Collect unique imports for generators
+	generatorImports := make(map[string]string) // path -> alias
+	seenGenImports := make(map[string]int)
+	for _, gen := range config.Generators {
+		alias := generateImportAlias(gen.V1Generator, seenGenImports)
+		generatorImports[gen.V1Generator] = alias
+
+		genInfo := GeneratorInfo{
+			V1Generator:     gen.V1Generator,
+			V1GeneratorFunc: gen.V1GeneratorFunc,
+			ImportAlias:     alias,
+		}
+		genInfo.GVK.Group = gen.GVK.Group
+		genInfo.GVK.Version = gen.GVK.Version
+		genInfo.GVK.Kind = gen.GVK.Kind
+
+		data.Generators = append(data.Generators, genInfo)
+	}
+
+	for path, alias := range generatorImports {
+		data.UniqueGeneratorImports = append(data.UniqueGeneratorImports, ImportInfo{
+			Path:  path,
+			Alias: alias,
+		})
+	}
+
+	return data
+}
+
+func generateImportAlias(importPath string, seenImports map[string]int) string {
+	// Extract the last segment of the import path
+	parts := strings.Split(importPath, "/")
+	lastPart := parts[len(parts)-1]
+
+	// If this is the first time we see this import path, use the package name
+	_, exists := seenImports[importPath]
+	if !exists {
+		seenImports[importPath] = 1
+		return lastPart
+	}
+
+	// If we've seen it before, return the same alias
+	return lastPart
+}
+
+func executeTemplate(tmpl *template.Template, data any) ([]byte, error) {
+	var buf bytes.Buffer
+	if err := tmpl.Execute(&buf, data); err != nil {
+		return nil, err
+	}
+	return buf.Bytes(), nil
+}
+
+func formatGoCode(code []byte) ([]byte, error) {
+	// Try goimports first (better formatting)
+	cmd := exec.Command("goimports")
+	cmd.Stdin = bytes.NewReader(code)
+	var out bytes.Buffer
+	var stderr bytes.Buffer
+	cmd.Stdout = &out
+	cmd.Stderr = &stderr
+
+	err := cmd.Run()
+	if err == nil {
+		return out.Bytes(), nil
+	}
+
+	// Fallback to gofmt if goimports is not available
+	cmd = exec.Command("gofmt")
+	cmd.Stdin = bytes.NewReader(code)
+	out.Reset()
+	stderr.Reset()
+	cmd.Stdout = &out
+	cmd.Stderr = &stderr
+
+	err = cmd.Run()
+	if err != nil {
+		return nil, fmt.Errorf("gofmt failed: %w, stderr: %s", err, stderr.String())
+	}
+
+	return out.Bytes(), nil
+}

+ 70 - 0
providers/v2/hack/generate_provider_main_test.go

@@ -0,0 +1,70 @@
+/*
+Copyright © The ESO 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
+
+    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 (
+	"strings"
+	"testing"
+)
+
+func TestMainTemplateStartsMetricsServer(t *testing.T) {
+	tmpl, err := loadTemplate("templates/main.go.tmpl")
+	if err != nil {
+		t.Fatalf("loadTemplate() error = %v", err)
+	}
+
+	data := prepareTemplateData(&ProviderConfig{
+		Provider: providerMetadata{
+			Name:        "kubernetes",
+			DisplayName: "Kubernetes",
+			V2Package:   "github.com/external-secrets/external-secrets/apis/provider/kubernetes/v2alpha1",
+		},
+		Stores: []storeConfig{
+			{
+				GVK: gvkConfig{
+					Group:   "provider.external-secrets.io",
+					Version: "v2alpha1",
+					Kind:    "Kubernetes",
+				},
+				V1Provider:     "github.com/external-secrets/external-secrets/providers/v1/kubernetes",
+				V1ProviderFunc: "NewProvider",
+			},
+		},
+		ConfigPackage: "./config.go",
+	})
+
+	rendered, err := executeTemplate(tmpl, data)
+	if err != nil {
+		t.Fatalf("executeTemplate() error = %v", err)
+	}
+
+	output := string(rendered)
+	expectedSnippets := []string{
+		"ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)",
+		"metricsServer := grpcserver.NewMetricsServer(grpcserver.DefaultMetricsPort, nil)",
+		"if err := grpcserver.RegisterMetrics(metricsServer.GetRegistry()); err != nil {",
+		"go func() {",
+		"if err := metricsServer.Start(ctx); err != nil {",
+		"log.Fatalf(\"Failed to start metrics server: %v\", err)",
+	}
+
+	for _, snippet := range expectedSnippets {
+		if !strings.Contains(output, snippet) {
+			t.Fatalf("generated main.go missing snippet %q\noutput:\n%s", snippet, output)
+		}
+	}
+}

+ 19 - 0
providers/v2/hack/go.mod

@@ -0,0 +1,19 @@
+module github.com/external-secrets/external-secrets/providers/v2/hack
+
+go 1.23
+
+require (
+	github.com/xeipuuv/gojsonschema v1.2.0
+	gopkg.in/yaml.v3 v3.0.1
+)
+
+require (
+	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+	github.com/kr/pretty v0.3.1 // indirect
+	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+	github.com/rogpeppe/go-internal v1.14.1 // indirect
+	github.com/stretchr/testify v1.11.1 // indirect
+	github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
+	github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
+	gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
+)

+ 34 - 0
providers/v2/hack/go.sum

@@ -0,0 +1,34 @@
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
+github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
+github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
+github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
+github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 115 - 0
providers/v2/hack/schema/provider-config.schema.json

@@ -0,0 +1,115 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$id": "https://external-secrets.io/schemas/provider-config.schema.json",
+  "title": "Provider Configuration",
+  "description": "Configuration schema for External Secrets Operator v2 provider code generation",
+  "type": "object",
+  "required": ["provider"],
+  "properties": {
+    "provider": {
+      "type": "object",
+      "required": ["name", "displayName"],
+      "properties": {
+        "name": {
+          "type": "string",
+          "pattern": "^[a-z][a-z0-9-]*$",
+          "description": "Provider name (lowercase, alphanumeric with hyphens)"
+        },
+        "displayName": {
+          "type": "string",
+          "description": "Human-readable provider name"
+        },
+        "v2Package": {
+          "type": "string",
+          "description": "Go import path for the v2alpha1 API package (optional if no stores)"
+        }
+      }
+    },
+    "stores": {
+      "type": ["array", "null"],
+      "description": "List of secret store implementations provided by this provider",
+      "items": {
+        "type": "object",
+        "required": ["gvk", "v1Provider", "v1ProviderFunc"],
+        "properties": {
+          "gvk": {
+            "type": "object",
+            "required": ["group", "version", "kind"],
+            "properties": {
+              "group": {
+                "type": "string",
+                "description": "API group"
+              },
+              "version": {
+                "type": "string",
+                "description": "API version"
+              },
+              "kind": {
+                "type": "string",
+                "description": "Resource kind"
+              }
+            }
+          },
+          "v1Provider": {
+            "type": "string",
+            "description": "Go import path for the v1 provider package"
+          },
+          "v1ProviderFunc": {
+            "type": "string",
+            "description": "Function name that returns the v1 provider instance"
+          }
+        }
+      }
+    },
+    "generators": {
+      "type": ["array", "null"],
+      "description": "List of generator implementations provided by this provider",
+      "items": {
+        "type": "object",
+        "required": ["gvk", "v1Generator", "v1GeneratorFunc"],
+        "properties": {
+          "gvk": {
+            "type": "object",
+            "required": ["group", "version", "kind"],
+            "properties": {
+              "group": {
+                "type": "string",
+                "description": "API group"
+              },
+              "version": {
+                "type": "string",
+                "description": "API version"
+              },
+              "kind": {
+                "type": "string",
+                "description": "Generator kind"
+              }
+            }
+          },
+          "v1Generator": {
+            "type": "string",
+            "description": "Go import path for the v1 generator package"
+          },
+          "v1GeneratorFunc": {
+            "type": "string",
+            "description": "Function name that returns the v1 generator instance"
+          }
+        }
+      }
+    },
+    "configPackage": {
+      "type": "string",
+      "description": "Relative import path for the config.go file containing GetSpecMapper function",
+      "default": "./config"
+    }
+  },
+  "anyOf": [
+    {
+      "required": ["stores"]
+    },
+    {
+      "required": ["generators"]
+    }
+  ]
+}
+

+ 25 - 0
providers/v2/hack/templates/Dockerfile.tmpl

@@ -0,0 +1,25 @@
+# Multi-stage build for {{.Provider.Provider.DisplayName}} Provider
+# Generated by providers/v2/hack/generate-provider-main.go. DO NOT EDIT.
+FROM golang:1.26.2-alpine AS builder
+
+WORKDIR /workspace
+COPY apis/ apis/
+COPY providers/ providers/
+COPY runtime/ runtime/
+COPY generators/ generators/
+
+# Build the provider binary
+WORKDIR /workspace/providers/v2/{{.Provider.Provider.Name}}
+RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -a -o provider-{{.Provider.Provider.Name}} .
+
+# Use distroless as minimal base image
+FROM gcr.io/distroless/static:nonroot
+
+WORKDIR /
+
+# Copy the binary
+COPY --from=builder /workspace/providers/v2/{{.Provider.Provider.Name}}/provider-{{.Provider.Provider.Name}} .
+
+USER 65532:65532
+
+ENTRYPOINT ["/provider-{{.Provider.Provider.Name}}"]

+ 191 - 0
providers/v2/hack/templates/main.go.tmpl

@@ -0,0 +1,191 @@
+/*
+Copyright © The ESO 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
+
+    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.
+*/
+
+// Code generated by providers/v2/hack/generate-provider-main.go. DO NOT EDIT.
+
+package main
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"log"
+	"net"
+	"os/signal"
+	"syscall"
+
+	"google.golang.org/grpc/health"
+	"google.golang.org/grpc/health/grpc_health_v1"
+	"google.golang.org/grpc/reflection"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/config"
+
+	{{- if .HasGenerators}}
+	genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
+	{{- end}}
+	{{- if .Provider.Provider.V2Package}}
+	{{.Provider.Provider.Name}}v2alpha1 "{{.Provider.Provider.V2Package}}"
+	{{- end}}
+	{{- if .HasGenerators}}
+	genpb "github.com/external-secrets/external-secrets/proto/generator"
+	{{- end}}
+	{{- if .HasStores}}
+	pb "github.com/external-secrets/external-secrets/proto/provider"
+	{{- end}}
+	{{- if .HasGenerators}}
+	adaptergenerator "github.com/external-secrets/external-secrets/providers/v2/adapter/generator"
+	{{- end}}
+	{{- if .HasStores}}
+	adapterstore "github.com/external-secrets/external-secrets/providers/v2/adapter/store"
+	{{- end}}
+	grpcserver "github.com/external-secrets/external-secrets/providers/v2/common/grpc/server"
+	{{- range .UniqueStoreImports}}
+	{{.Alias}} "{{.Path}}"
+	{{- end}}
+	{{- range .UniqueGeneratorImports}}
+	{{.Alias}} "{{.Path}}"
+	{{- end}}
+)
+
+var (
+	port      = flag.Int("port", 8080, "The server port")
+	enableTLS = flag.Bool("enable-tls", true, "Enable TLS/mTLS for gRPC server")
+	verbose   = flag.Bool("verbose", false, "Enable verbose connection-level debugging")
+)
+
+func main() {
+	flag.Parse()
+	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer stop()
+
+	log.Printf("starting on port %d (TLS: %v, Verbose: %v)", *port, *enableTLS, *verbose)
+
+	// Create Kubernetes client (required by adapter)
+	scheme := runtime.NewScheme()
+	_ = clientgoscheme.AddToScheme(scheme)
+	{{- if .Provider.Provider.V2Package}}
+	_ = {{.Provider.Provider.Name}}v2alpha1.AddToScheme(scheme)
+	{{- end}}
+	{{- if .HasGenerators}}
+	_ = genv1alpha1.AddToScheme(scheme)
+	{{- end}}
+
+	cfg, err := config.GetConfig()
+	if err != nil {
+		log.Fatalf("Failed to get kubeconfig: %v", err)
+	}
+
+	kubeClient, err := client.New(cfg, client.Options{Scheme: scheme})
+	if err != nil {
+		log.Fatalf("Failed to create Kubernetes client: %v", err)
+	}
+
+	{{- if .HasStores}}
+	// Setup v1 provider(s)
+	{{- range $idx, $store := .Stores}}
+	v1Provider{{$idx}} := {{$store.ImportAlias}}.{{$store.V1ProviderFunc}}()
+	{{- end}}
+	compatibilityProvider := v1Provider0
+	providerMapping := adapterstore.ProviderMapping{
+		{{- range $idx, $store := .Stores}}
+		schema.GroupVersionKind{
+			Group:   "{{$store.GVK.Group}}",
+			Version: "{{$store.GVK.Version}}",
+			Kind:    "{{$store.GVK.Kind}}",
+		}: v1Provider{{$idx}},
+		{{- end}}
+	}
+
+	specMapper := GetSpecMapper(kubeClient)
+	{{- end}}
+
+	{{- if .HasGenerators}}
+	// Setup v1 generator(s)
+	generatorMapping := adaptergenerator.Mapping{
+		{{- range $idx, $gen := .Generators}}
+		schema.GroupVersionKind{
+			Group:   "{{$gen.GVK.Group}}",
+			Version: "{{$gen.GVK.Version}}",
+			Kind:    "{{$gen.GVK.Kind}}",
+		}: {{$gen.ImportAlias}}.{{$gen.V1GeneratorFunc}}(),
+		{{- end}}
+	}
+	{{- end}}
+
+	{{- if .HasStores}}
+	storeServer := adapterstore.NewServerWithCompatibilityProvider(kubeClient, providerMapping, specMapper, compatibilityProvider)
+	{{- end}}
+	{{- if .HasGenerators}}
+	generatorServer := adaptergenerator.NewServer(kubeClient, scheme, generatorMapping)
+	{{- end}}
+
+	log.Printf("[PROVIDER] Using v1 {{.Provider.Provider.DisplayName}} provider{{if .HasGenerators}} with generators{{end}} wrapped with v2 adapter")
+	grpcServer, err := grpcserver.NewGRPCServer(grpcserver.ServerOptions{
+		EnableTLS: *enableTLS,
+		Verbose:   *verbose,
+	})
+	if err != nil {
+		log.Fatalf("Failed to create gRPC server: %v", err)
+	}
+	metricsServer := grpcserver.NewMetricsServer(grpcserver.DefaultMetricsPort, nil)
+	if err := grpcserver.RegisterMetrics(metricsServer.GetRegistry()); err != nil {
+		log.Fatalf("Failed to register metrics: %v", err)
+	}
+
+	// Register services
+	{{- if .HasStores}}
+	pb.RegisterSecretStoreProviderServer(grpcServer, storeServer)
+	{{- end}}
+	{{- if .HasGenerators}}
+	genpb.RegisterGeneratorProviderServer(grpcServer, generatorServer)
+	{{- end}}
+
+	// Register health service
+	healthServer := health.NewServer()
+	grpc_health_v1.RegisterHealthServer(grpcServer, healthServer)
+	healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)
+
+	// Register reflection service for debugging
+	reflection.Register(grpcServer)
+
+	// Start listening
+	lis, err := net.Listen("tcp", fmt.Sprintf(":%d", *port))
+	if err != nil {
+		log.Fatalf("Failed to listen: %v", err)
+	}
+
+	go func() {
+		if err := metricsServer.Start(ctx); err != nil {
+			log.Fatalf("Failed to start metrics server: %v", err)
+		}
+	}()
+
+	// Handle graceful shutdown
+	go func() {
+		<-ctx.Done()
+		log.Printf("Received shutdown signal, stopping gRPC server...")
+		grpcServer.GracefulStop()
+	}()
+
+	// Start serving
+	log.Printf("{{.Provider.Provider.DisplayName}} Provider listening on %s", lis.Addr().String())
+	if err := grpcServer.Serve(lis); err != nil {
+		log.Fatalf("Failed to serve: %v", err)
+	}
+}