Browse Source

feat: add a renderer for template data and secrets (#4277)

* feat: add a renderer for template data and secrets

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>

* updated to use files and to bypass the client

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>

* refactor and added documentation for using the tool

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>

* moved around the render tool into its own folder

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>

* add release github action

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>

* added goreleaser flow including signing and sboms

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>

* restructure the location of the render tool to cmd/render and the others to cmd/controller

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>

* renamed to esoctl and made render a subcommand called template

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>

* remove duplication

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>

---------

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>
Gergely Brautigam 1 year ago
parent
commit
914e40b640

+ 71 - 0
.github/workflows/release_render.yml

@@ -0,0 +1,71 @@
+name: Create Release for Render
+
+on:
+  workflow_dispatch:
+    inputs:
+      version:
+        description: 'version to release, e.g. v0.1.0-render'
+        required: true
+        default: 'v0.1.0-render'
+      source_ref:
+        description: 'source ref to publish from. E.g.: main or release-x.y'
+        required: true
+        default: 'main'
+
+jobs:
+  release:
+    name: Create Release for Render
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+        with:
+          fetch-depth: 0
+          ref: ${{ github.event.inputs.source_ref }}
+
+      - name: Setup Go
+        uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0
+        id: setup-go
+        with:
+          go-version-file: "go.mod"
+
+      - name: Download Go modules
+        if: ${{ steps.setup-go.outputs.cache-hit != 'true' }}
+        run: go mod download
+
+      - name: Install Syft
+        uses: anchore/sbom-action/download-syft@v0.7.0
+
+      - name: Import GPG key
+        id: import_gpg
+        uses: crazy-max/ghaction-import-gpg@v6
+        with:
+          gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
+          passphrase: ${{ secrets.GPG_PASSPHRASE }}
+
+      - name: Check if Tag Exists
+        id: check_tag
+        run: |
+          if git rev-parse "${{ github.event.inputs.version }}" >/dev/null 2>&1; then
+            echo "Tag exists."
+            exit 1
+          fi
+
+      - name: Create Tag if Not Exists
+        if: success()
+        run: |
+          TAG="${{ github.event.inputs.version }}"
+          git tag $TAG
+          git push origin $TAG
+
+      - name: Run GoReleaser
+        uses: goreleaser/goreleaser-action@v6.1.0
+        with:
+          version: '~> v2'
+          args: release --clean
+          workdir: cmd/render
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GORELEASER_CURRENT_TAG: ${{ github.event.inputs.version }}
+          GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}

+ 2 - 0
.gitignore

@@ -2,6 +2,8 @@
 /webhook/bin
 /webhook/certcontroller/bin
 /bin
+/cmd/render/bin
+/cmd/render/dist
 /vendor
 cover.out
 

+ 23 - 19
cmd/certcontroller.go

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-package cmd
+package controller
 
 import (
 	"os"
@@ -45,24 +45,7 @@ var certcontrollerCmd = &cobra.Command{
 	Long: `Controller to manage certificates for external secrets CRDs and ValidatingWebhookConfigs.
 	For more information visit https://external-secrets.io`,
 	Run: func(cmd *cobra.Command, args []string) {
-		var lvl zapcore.Level
-		var enc zapcore.TimeEncoder
-		lvlErr := lvl.UnmarshalText([]byte(loglevel))
-		if lvlErr != nil {
-			setupLog.Error(lvlErr, "error unmarshalling loglevel")
-			os.Exit(1)
-		}
-		encErr := enc.UnmarshalText([]byte(zapTimeEncoding))
-		if encErr != nil {
-			setupLog.Error(encErr, "error unmarshalling timeEncoding")
-			os.Exit(1)
-		}
-		opts := zap.Options{
-			Level:       lvl,
-			TimeEncoder: enc,
-		}
-		logger := zap.New(zap.UseFlagOptions(&opts))
-		ctrl.SetLogger(logger)
+		setupLogger()
 
 		// completely disable caching of Secrets and ConfigMaps to save memory
 		// see: https://github.com/external-secrets/external-secrets/issues/721
@@ -165,6 +148,27 @@ var certcontrollerCmd = &cobra.Command{
 	},
 }
 
+func setupLogger() {
+	var lvl zapcore.Level
+	var enc zapcore.TimeEncoder
+	lvlErr := lvl.UnmarshalText([]byte(loglevel))
+	if lvlErr != nil {
+		setupLog.Error(lvlErr, "error unmarshalling loglevel")
+		os.Exit(1)
+	}
+	encErr := enc.UnmarshalText([]byte(zapTimeEncoding))
+	if encErr != nil {
+		setupLog.Error(encErr, "error unmarshalling timeEncoding")
+		os.Exit(1)
+	}
+	opts := zap.Options{
+		Level:       lvl,
+		TimeEncoder: enc,
+	}
+	logger := zap.New(zap.UseFlagOptions(&opts))
+	ctrl.SetLogger(logger)
+}
+
 func init() {
 	rootCmd.AddCommand(certcontrollerCmd)
 

+ 3 - 21
cmd/root.go

@@ -14,14 +14,13 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-package cmd
+package controller
 
 import (
 	"os"
 	"time"
 
 	"github.com/spf13/cobra"
-	"go.uber.org/zap/zapcore"
 	v1 "k8s.io/api/core/v1"
 	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	"k8s.io/apimachinery/pkg/runtime"
@@ -31,7 +30,6 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/cache"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/controller"
-	"sigs.k8s.io/controller-runtime/pkg/log/zap"
 	"sigs.k8s.io/controller-runtime/pkg/metrics/server"
 	"sigs.k8s.io/controller-runtime/pkg/webhook"
 
@@ -110,24 +108,8 @@ var rootCmd = &cobra.Command{
 	Short: "operator that reconciles ExternalSecrets and SecretStores",
 	Long:  `For more information visit https://external-secrets.io`,
 	Run: func(cmd *cobra.Command, args []string) {
-		var lvl zapcore.Level
-		var enc zapcore.TimeEncoder
-		lvlErr := lvl.UnmarshalText([]byte(loglevel))
-		if lvlErr != nil {
-			setupLog.Error(lvlErr, "error unmarshalling loglevel")
-			os.Exit(1)
-		}
-		encErr := enc.UnmarshalText([]byte(zapTimeEncoding))
-		if encErr != nil {
-			setupLog.Error(encErr, "error unmarshalling timeEncoding")
-			os.Exit(1)
-		}
-		opts := zap.Options{
-			Level:       lvl,
-			TimeEncoder: enc,
-		}
-		logger := zap.New(zap.UseFlagOptions(&opts))
-		ctrl.SetLogger(logger)
+		setupLogger()
+
 		ctrlmetrics.SetUpLabelNames(enableExtendedMetricLabels)
 		esmetrics.SetUpMetrics()
 		config := ctrl.GetConfigOrDie()

+ 3 - 22
cmd/webhook.go

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-package cmd
+package controller
 
 import (
 	"context"
@@ -28,11 +28,9 @@ import (
 	"time"
 
 	"github.com/spf13/cobra"
-	"go.uber.org/zap/zapcore"
 	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/log/zap"
 	"sigs.k8s.io/controller-runtime/pkg/metrics/server"
 	"sigs.k8s.io/controller-runtime/pkg/webhook"
 
@@ -60,31 +58,14 @@ var webhookCmd = &cobra.Command{
 	Long: `Webhook implementation for ExternalSecrets and SecretStores.
 	For more information visit https://external-secrets.io`,
 	Run: func(cmd *cobra.Command, args []string) {
-		var lvl zapcore.Level
-		var enc zapcore.TimeEncoder
+		setupLogger()
+
 		c := crds.CertInfo{
 			CertDir:  certDir,
 			CertName: "tls.crt",
 			KeyName:  "tls.key",
 			CAName:   "ca.crt",
 		}
-		lvlErr := lvl.UnmarshalText([]byte(loglevel))
-		if lvlErr != nil {
-			setupLog.Error(lvlErr, "error unmarshalling loglevel")
-			os.Exit(1)
-		}
-		encErr := enc.UnmarshalText([]byte(zapTimeEncoding))
-		if encErr != nil {
-			setupLog.Error(encErr, "error unmarshalling timeEncoding")
-			os.Exit(1)
-		}
-		opts := zap.Options{
-			Level:       lvl,
-			TimeEncoder: enc,
-		}
-		logger := zap.New(zap.UseFlagOptions(&opts))
-		ctrl.SetLogger(logger)
-
 		err := waitForCerts(c, time.Minute*2)
 		if err != nil {
 			setupLog.Error(err, "unable to validate certificates")

+ 33 - 0
cmd/esoctl/.goreleaser.yaml

@@ -0,0 +1,33 @@
+version: 2
+builds:
+  - id: default
+    binary: esoctl
+    flags:
+      - -tags
+      - netgo release
+      - -trimpath
+    env:
+      - CGO_ENABLED=0
+    goos:
+      - linux
+      - darwin
+      - windows
+    goarch:
+      - amd64
+
+archives:
+  - id: default
+    builds:
+      - default
+    name_template: "esoctl_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
+    format: tar.gz
+
+signs:
+  - artifacts: checksum
+    args: ["--batch", "-u", "{{ .Env.GPG_FINGERPRINT }}", "--output", "${signature}", "--detach-sign", "${artifact}"]
+
+sboms:
+  - artifacts: archive
+
+checksum:
+  name_template: "esoctl_checksums.txt"

+ 35 - 0
cmd/esoctl/Makefile

@@ -0,0 +1,35 @@
+# set the shell to bash always
+SHELL         := /bin/bash
+
+# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set)
+ifeq (,$(shell go env GOBIN))
+GOBIN=$(shell go env GOPATH)/bin
+else
+GOBIN=$(shell go env GOBIN)
+endif
+
+# check if there are any existing `git tag` values
+ifeq ($(shell git tag),)
+# no tags found - default to initial tag `v0.0.0`
+export VERSION := $(shell echo "v0.0.0-$$(git rev-list HEAD --count)-g$$(git describe --dirty --always)" | sed 's/-/./2' | sed 's/-/./2')
+else
+# use tags
+export VERSION := $(shell git describe --dirty --always --tags --exclude 'helm*' | sed 's/-/./2' | sed 's/-/./2')
+endif
+
+## Location to install dependencies to
+LOCALBIN ?= $(shell pwd)/bin
+$(LOCALBIN):
+	mkdir -p $(LOCALBIN)
+
+.PHONY: build
+build: ## Build binary for the specified arch
+	go build -o '$(LOCALBIN)/esoctl' -trimpath -ldflags="-s -w -X 'main.version=$(VERSION)'" .
+
+.PHONY: binaries
+binaries: ## Build release binaries for all major OSs.
+	@rm -fr dist
+	@mkdir -p dist
+	GOOS=linux GOARCH=amd64 go build -o dist/esoctl-linux-amd64 -trimpath -ldflags="-s -w -X 'main.version=$(VERSION)'" .
+	GOOS=darwin GOARCH=amd64 go build -o dist/esoctl-darwin-amd64 -trimpath -ldflags="-s -w -X 'main.version=$(VERSION)'" .
+	GOOS=windows GOARCH=amd64 go build -o dist/esoctl-windows-amd64.exe -trimpath -ldflags="-s -w -X 'main.version=$(VERSION)'" .

+ 14 - 0
cmd/esoctl/README.md

@@ -0,0 +1,14 @@
+# esoctl
+
+This tool contains external-secrets-operator related activities and helpers.
+
+## Templates
+
+`cmd/render` -> `esoctl template`
+
+The purpose is to give users the ability to rapidly test and iterate on templates in a PushSecret/ExternalSecret.
+
+For a more in-dept description read [Using Render Tool](../../docs/guides/using-render-tool.md).
+
+This project doesn't have its own go mod files to allow it to grow together with ESO instead of waiting for new ESO
+releases to import it.

+ 29 - 0
cmd/esoctl/main.go

@@ -0,0 +1,29 @@
+/*
+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
+
+	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 main
+
+import (
+	"fmt"
+	"os"
+)
+
+func main() {
+	if err := rootCmd.Execute(); err != nil {
+		_, _ = fmt.Fprintln(os.Stderr, err)
+		os.Exit(1)
+	}
+}

+ 28 - 0
cmd/esoctl/root.go

@@ -0,0 +1,28 @@
+/*
+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
+
+	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 main
+
+import "github.com/spf13/cobra"
+
+var rootCmd = &cobra.Command{
+	Use:   "esoctl",
+	Short: "operations for external-secrets-operator",
+	Long:  `For more information visit https://external-secrets.io`,
+	Run: func(cmd *cobra.Command, args []string) {
+		_ = cmd.Usage()
+	},
+}

+ 230 - 0
cmd/esoctl/template.go

@@ -0,0 +1,230 @@
+/*
+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
+
+	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 main
+
+import (
+	"context"
+	"fmt"
+	"os"
+
+	"github.com/spf13/cobra"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+	"sigs.k8s.io/yaml"
+
+	"github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
+	"github.com/external-secrets/external-secrets/pkg/controllers/templating"
+	"github.com/external-secrets/external-secrets/pkg/template"
+)
+
+// version is filled during build time.
+var version string
+
+var (
+	templateFile              string
+	secretDataFile            string
+	outputFile                string
+	templateFromConfigMapFile string
+	templateFromSecretFile    string
+	showVersion               bool
+)
+
+func init() {
+	rootCmd.AddCommand(templateCmd)
+	templateCmd.Flags().StringVar(&templateFile, "source-templated-object", "", "Link to a file containing the object that contains the template")
+	templateCmd.Flags().StringVar(&secretDataFile, "source-secret-data-file", "", "Link to a file containing secret data in form of map[string][]byte")
+	templateCmd.Flags().StringVar(&templateFromConfigMapFile, "template-from-config-map", "", "Link to a file containing config map data for TemplateFrom.ConfigMap")
+	templateCmd.Flags().StringVar(&templateFromSecretFile, "template-from-secret", "", "Link to a file containing config map data for TemplateFrom.Secret")
+	templateCmd.Flags().StringVar(&outputFile, "output", "", "If set, the output will be written to this file")
+	templateCmd.Flags().BoolVar(&showVersion, "version", false, "If set, only print the version and exit")
+}
+
+var templateCmd = &cobra.Command{
+	Use:   "template",
+	Short: "given an input and a template provides an output",
+	Long:  `Given an input that mimics a secret's data section and a template it produces an output of the render template.`,
+	RunE:  templateRun,
+}
+
+func templateRun(_ *cobra.Command, _ []string) error {
+	if version == "" {
+		version = "0.0.0-dev"
+	}
+
+	if showVersion {
+		fmt.Printf("version: %s\n", version)
+
+		os.Exit(0)
+	}
+
+	ctx := context.Background()
+	obj := &unstructured.Unstructured{}
+	content, err := os.ReadFile(templateFile)
+	if err != nil {
+		return fmt.Errorf("could not read template file: %w", err)
+	}
+
+	if err := yaml.Unmarshal(content, obj); err != nil {
+		return fmt.Errorf("could not unmarshal template: %w", err)
+	}
+
+	tmpl, err := fetchTemplateFromSourceObject(obj)
+	if err != nil {
+		return err
+	}
+
+	data := map[string][]byte{}
+	sourceDataContent, err := os.ReadFile(secretDataFile)
+	if err != nil {
+		return fmt.Errorf("could not read source secret file: %w", err)
+	}
+
+	if err := yaml.Unmarshal(sourceDataContent, &data); err != nil {
+		return fmt.Errorf("could not unmarshal secret: %w", err)
+	}
+
+	execute, err := template.EngineForVersion(tmpl.EngineVersion)
+	if err != nil {
+		return err
+	}
+
+	targetSecret := &corev1.Secret{}
+	p := &templating.Parser{
+		TargetSecret: targetSecret,
+		DataMap:      data,
+		Exec:         execute,
+	}
+
+	if err := setupFromConfigAndFromSecret(p); err != nil {
+		return fmt.Errorf("could not setup from secret: %w", err)
+	}
+
+	if err := executeTemplate(p, ctx, tmpl); err != nil {
+		return fmt.Errorf("could not render template: %w", err)
+	}
+
+	out := os.Stdout
+	if outputFile != "" {
+		f, err := os.Create(outputFile)
+		if err != nil {
+			return fmt.Errorf("could not create output file: %w", err)
+		}
+		defer func() {
+			_ = f.Close()
+		}()
+
+		out = f
+	}
+
+	// display the resulting secret
+	content, err = yaml.Marshal(targetSecret)
+	if err != nil {
+		return fmt.Errorf("could not marshal secret: %w", err)
+	}
+
+	_, err = fmt.Fprintln(out, string(content))
+
+	return err
+}
+
+func fetchTemplateFromSourceObject(obj *unstructured.Unstructured) (*esv1beta1.ExternalSecretTemplate, error) {
+	var tmpl *esv1beta1.ExternalSecretTemplate
+	switch obj.GetKind() {
+	case "ExternalSecret":
+		es := &esv1beta1.ExternalSecret{}
+		if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, es); err != nil {
+			return nil, err
+		}
+
+		tmpl = es.Spec.Target.Template
+	case "PushSecret":
+		ps := &v1alpha1.PushSecret{}
+		if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, ps); err != nil {
+			return nil, err
+		}
+
+		tmpl = ps.Spec.Template
+	default:
+		return nil, fmt.Errorf("unsupported template kind %s", obj.GetKind())
+	}
+
+	return tmpl, nil
+}
+
+func executeTemplate(p *templating.Parser, ctx context.Context, tmpl *esv1beta1.ExternalSecretTemplate) error {
+	// apply templates defined in template.templateFrom
+	err := p.MergeTemplateFrom(ctx, "default", tmpl)
+	if err != nil {
+		return fmt.Errorf("could not merge template: %w", err)
+	}
+
+	// apply data templates
+	// NOTE: explicitly defined template.data templates take precedence over templateFrom
+	err = p.MergeMap(tmpl.Data, esv1beta1.TemplateTargetData)
+	if err != nil {
+		return fmt.Errorf("could not merge data: %w", err)
+	}
+
+	// apply templates for labels
+	// NOTE: this only works for v2 templates
+	err = p.MergeMap(tmpl.Metadata.Labels, esv1beta1.TemplateTargetLabels)
+	if err != nil {
+		return fmt.Errorf("could not merge labels: %w", err)
+	}
+
+	// apply template for annotations
+	// NOTE: this only works for v2 templates
+	err = p.MergeMap(tmpl.Metadata.Annotations, esv1beta1.TemplateTargetAnnotations)
+	if err != nil {
+		return fmt.Errorf("could not merge annotations: %w", err)
+	}
+
+	return err
+}
+
+func setupFromConfigAndFromSecret(p *templating.Parser) error {
+	if templateFromConfigMapFile != "" {
+		var configMap corev1.ConfigMap
+		configMapContent, err := os.ReadFile(templateFromConfigMapFile)
+		if err != nil {
+			return err
+		}
+
+		if err := yaml.Unmarshal(configMapContent, &configMap); err != nil {
+			return fmt.Errorf("could not unmarshal configmap: %w", err)
+		}
+
+		p.TemplateFromConfigMap = &configMap
+	}
+
+	if templateFromSecretFile != "" {
+		var secret corev1.Secret
+		secretContent, err := os.ReadFile(templateFromSecretFile)
+		if err != nil {
+			return err
+		}
+
+		if err := yaml.Unmarshal(secretContent, &secret); err != nil {
+			return fmt.Errorf("could not unmarshal secret: %w", err)
+		}
+
+		p.TemplateFromSecret = &secret
+	}
+	return nil
+}

+ 78 - 0
docs/guides/using-render-tool.md

@@ -0,0 +1,78 @@
+# Using the Render tool
+
+The tool can be found under `cmd/esoctl`. 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`.
+
+Once the build succeeds, the command can be used as such:
+```console
+./bin/esoctl template --source-templated-object template-test/push-secret.yaml --source-secret-data-file template-test/secret.yaml
+```
+
+Where template-test looks like this:
+
+```
+❯ tree template-test/                                                                                                                                                                                                                   (base)
+template-test/
+├── push-secret.yaml
+└── secret.yaml
+
+1 directory, 2 files
+```
+
+`PushSecret` is simply the following:
+
+```yaml
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: example-push-secret-with-template
+spec:
+  refreshInterval: 10s
+  secretStoreRefs:
+    - name: secret-store-name
+      kind: SecretStore
+  selector:
+    secret:
+      name: git-sync-secret
+  template:
+    engineVersion: v2
+    data:
+      token: "{{ .token | toString | upper }} was templated"
+  data:
+    - match:
+        secretKey: token
+        remoteRef:
+          remoteKey: git-sync-secret-copy-templated
+          property: token
+```
+
+And secret data is:
+
+```yaml
+token: dG9rZW4=
+```
+
+Therefor if there is a PushSecret or an ExternalSecret object that the user would like to test the template for,
+simply put it into a file along with the data it's using, and run this command.
+
+The output will be something like this:
+
+```console
+➜ ./bin/esoctl template --source-templated-object template-test/push-secret.yaml --source-secret-data-file template-test/secret.yaml                                                                                                          
+data:
+  token: VE9LRU4gd2FzIHRlbXBsYXRlZA==
+metadata:
+  creationTimestamp: null
+
+➜ echo -n "VE9LRU4gd2FzIHRlbXBsYXRlZA==" | base64 -d                                                                                                                                                                                    
+TOKEN was templated⏎
+```
+
+Further options can be used to provide templates from a ConfigMap or a Secret:
+```
+➜ ./bin/esoctl template --source-templated-object template-test/push-secret.yaml \
+    --source-secret-data-file template-test/secret.yaml \
+    --template-from-config-map template-test/template-config-map.yaml \
+    --template-from-secret template-test/template-secret.yaml
+```

+ 2 - 0
hack/api-docs/mkdocs.yml

@@ -104,6 +104,8 @@ nav:
           - Upgrading to v1beta1: guides/v1beta1.md
           - Using Latest Image: guides/using-latest-image.md
           - Disable Cluster Features: guides/disable-cluster-features.md
+      - Tooling:
+          - Using the Render tool: guides/using-render-tool.md
   - Provider:
       - AWS Secrets Manager: provider/aws-secrets-manager.md
       - AWS Parameter Store: provider/aws-parameter-store.md

+ 4 - 2
main.go

@@ -16,8 +16,10 @@ limitations under the License.
 
 package main
 
-import "github.com/external-secrets/external-secrets/cmd"
+import (
+	"github.com/external-secrets/external-secrets/cmd/controller"
+)
 
 func main() {
-	cmd.Execute()
+	controller.Execute()
 }

+ 29 - 14
pkg/controllers/templating/parser.go

@@ -41,20 +41,29 @@ type Parser struct {
 	DataMap      map[string][]byte
 	Client       client.Client
 	TargetSecret *v1.Secret
+
+	TemplateFromConfigMap *v1.ConfigMap
+	TemplateFromSecret    *v1.Secret
 }
 
 func (p *Parser) MergeConfigMap(ctx context.Context, namespace string, tpl esv1beta1.TemplateFrom) error {
 	if tpl.ConfigMap == nil {
 		return nil
 	}
+
 	var cm v1.ConfigMap
-	err := p.Client.Get(ctx, types.NamespacedName{
-		Name:      tpl.ConfigMap.Name,
-		Namespace: namespace,
-	}, &cm)
-	if err != nil {
-		return err
+	if p.TemplateFromConfigMap != nil {
+		cm = *p.TemplateFromConfigMap
+	} else {
+		err := p.Client.Get(ctx, types.NamespacedName{
+			Name:      tpl.ConfigMap.Name,
+			Namespace: namespace,
+		}, &cm)
+		if err != nil {
+			return err
+		}
 	}
+
 	for _, k := range tpl.ConfigMap.Items {
 		val, ok := cm.Data[k.Key]
 		out := make(map[string][]byte)
@@ -67,7 +76,7 @@ func (p *Parser) MergeConfigMap(ctx context.Context, namespace string, tpl esv1b
 		case esv1beta1.TemplateScopeKeysAndValues:
 			out[val] = []byte(val)
 		}
-		err = p.Exec(out, p.DataMap, k.TemplateAs, tpl.Target, p.TargetSecret)
+		err := p.Exec(out, p.DataMap, k.TemplateAs, tpl.Target, p.TargetSecret)
 		if err != nil {
 			return err
 		}
@@ -79,14 +88,20 @@ func (p *Parser) MergeSecret(ctx context.Context, namespace string, tpl esv1beta
 	if tpl.Secret == nil {
 		return nil
 	}
+
 	var sec v1.Secret
-	err := p.Client.Get(ctx, types.NamespacedName{
-		Name:      tpl.Secret.Name,
-		Namespace: namespace,
-	}, &sec)
-	if err != nil {
-		return err
+	if p.TemplateFromSecret != nil {
+		sec = *p.TemplateFromSecret
+	} else {
+		err := p.Client.Get(ctx, types.NamespacedName{
+			Name:      tpl.Secret.Name,
+			Namespace: namespace,
+		}, &sec)
+		if err != nil {
+			return err
+		}
 	}
+
 	for _, k := range tpl.Secret.Items {
 		val, ok := sec.Data[k.Key]
 		if !ok {
@@ -99,7 +114,7 @@ func (p *Parser) MergeSecret(ctx context.Context, namespace string, tpl esv1beta
 		case esv1beta1.TemplateScopeKeysAndValues:
 			out[string(val)] = val
 		}
-		err = p.Exec(out, p.DataMap, k.TemplateAs, tpl.Target, p.TargetSecret)
+		err := p.Exec(out, p.DataMap, k.TemplateAs, tpl.Target, p.TargetSecret)
 		if err != nil {
 			return err
 		}