Repository: arttor/helmify

Issue:
allow for wrapping of CRDs in a conditional
# Problem
I have CRDs that are not in `crds/` that I would like to be optional

# Desired Behavior
be able to wrap in a conditional (ie: `{{- if .Values.crds.enabled }}..{{- end }}`) to allow for them to be enabled/disabled via `values.yaml`



Discussion:

Solution patch:
diff --git a/.gitignore b/.gitignore
index 27ebaf5..bc70254 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,4 @@ testbin/*
 
 dist/
 
+vendor/
diff --git a/README.md b/README.md
index 430ad0a..f7d8354 100644
--- a/README.md
+++ b/README.md
@@ -113,6 +113,7 @@ Usage:
 | -cert-manager-install-crd     | Allows the user to install cert-manager CRD as part of the cert-manager subchart.(default "true")                                                                                                           | `helmify -cert-manager-install-crd` |
 | -preserve-ns              | Allows users to use the object's original namespace instead of adding all the resources to a common namespace. (default "false")                                                                            | `helmify -preserve-ns`              |
 | -add-webhook-option | Adds an option to enable/disable webhook installation  | `helmify -add-webhook-option`|
+| -optional-crds | Enable optional CRD installation through values. | `helmify -optional-crds` |
 ## Status
 Supported k8s resources:
 - Deployment, DaemonSet, StatefulSet
diff --git a/cmd/helmify/flags.go b/cmd/helmify/flags.go
index 7534afb..bdce7a8 100644
--- a/cmd/helmify/flags.go
+++ b/cmd/helmify/flags.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"errors"
 	"flag"
 	"fmt"
 	"os"
@@ -38,6 +39,9 @@ Flags:
 
 type arrayFlags []string
 
+var osExit = os.Exit
+var errMutuallyExclusiveCRDs = errors.New("-crd and -optional-crds cannot be used together")
+
 func (i *arrayFlags) String() string {
 	if i == nil || len(*i) == 0 {
 		return ""
@@ -51,48 +55,47 @@ func (i *arrayFlags) Set(value string) error {
 }
 
 // ReadFlags command-line flags into app config.
-func ReadFlags() config.Config {
+func ReadFlags() (config.Config, error) {
 	files := arrayFlags{}
 	result := config.Config{}
-	var h, help, version, crd, preservens bool
+	var h, help, version bool
 	flag.BoolVar(&h, "h", false, "Print help. Example: helmify -h")
 	flag.BoolVar(&help, "help", false, "Print help. Example: helmify -help")
 	flag.BoolVar(&version, "version", false, "Print helmify version. Example: helmify -version")
 	flag.BoolVar(&result.Verbose, "v", false, "Enable verbose output (print WARN & INFO). Example: helmify -v")
 	flag.BoolVar(&result.VeryVerbose, "vv", false, "Enable very verbose output. Same as verbose but with DEBUG. Example: helmify -vv")
-	flag.BoolVar(&crd, "crd-dir", false, "Enable crd install into 'crds' directory.\nWarning: CRDs placed in 'crds' directory will not be templated by Helm.\nSee https://helm.sh/docs/chart_best_practices/custom_resource_definitions/#some-caveats-and-explanations\nExample: helmify -crd-dir")
-	flag.BoolVar(&result.ImagePullSecrets, "image-pull-secrets", false, "Allows the user to use existing secrets as imagePullSecrets in values.yaml")
+	flag.BoolVar(&result.Crd, "crd-dir", false, "Enable crd install into 'crds' directory. (cannot be used with 'optional-crds').\nWarning: CRDs placed in 'crds' directory will not be templated by Helm.\nSee https://helm.sh/docs/chart_best_practices/custom_resource_definitions/#some-caveats-and-explanations\nExample: helmify -crd-dir")
+	flag.BoolVar(&result.ImagePullSecrets, "image-pull-secrets", false, "Allows the user to use existing secrets as imagePullSecrets in values.yaml.")
 	flag.BoolVar(&result.GenerateDefaults, "generate-defaults", false, "Allows the user to add empty placeholders for typical customization options in values.yaml. Currently covers: topology constraints, node selectors, tolerances")
 	flag.BoolVar(&result.CertManagerAsSubchart, "cert-manager-as-subchart", false, "Allows the user to add cert-manager as a subchart")
 	flag.StringVar(&result.CertManagerVersion, "cert-manager-version", "v1.12.2", "Allows the user to specify cert-manager subchart version. Only useful with cert-manager-as-subchart.")
 	flag.BoolVar(&result.CertManagerInstallCRD, "cert-manager-install-crd", true, "Allows the user to install cert-manager CRD. Only useful with cert-manager-as-subchart.")
 	flag.BoolVar(&result.FilesRecursively, "r", false, "Scan dirs from -f option recursively")
 	flag.BoolVar(&result.OriginalName, "original-name", false, "Use the object's original name instead of adding the chart's release name as the common prefix.")
-	flag.Var(&files, "f", "File or directory containing k8s manifests")
-	flag.BoolVar(&preservens, "preserve-ns", false, "Use the object's original namespace instead of adding all the resources to a common namespace")
-	flag.BoolVar(&result.AddWebhookOption, "add-webhook-option", false, "Allows the user to add webhook option in values.yaml")
+	flag.Var(&files, "f", "File or directory containing k8s manifests.")
+	flag.BoolVar(&result.PreserveNs, "preserve-ns", false, "Use the object's original namespace instead of adding all the resources to a common namespace.")
+	flag.BoolVar(&result.AddWebhookOption, "add-webhook-option", false, "Allows the user to add webhook option in values.yaml.")
+	flag.BoolVar(&result.OptionalCRDs, "optional-crds", false, "Enable optional CRD installation through values. (cannot be used with 'crd-dir')")
 
 	flag.Parse()
 	if h || help {
 		fmt.Print(helpText)
+		flag.CommandLine.SetOutput(os.Stdout)
 		flag.PrintDefaults()
-		os.Exit(0)
+		osExit(0)
 	}
 	if version {
 		printVersion()
-		os.Exit(0)
+		osExit(0)
 	}
 	name := flag.Arg(0)
 	if name != "" {
 		result.ChartName = filepath.Base(name)
 		result.ChartDir = filepath.Dir(name)
 	}
-	if crd {
-		result.Crd = crd
-	}
-	if preservens {
-		result.PreserveNs = true
+	if result.Crd && result.OptionalCRDs {
+		return config.Config{}, errMutuallyExclusiveCRDs
 	}
 	result.Files = files
-	return result
+	return result, nil
 }
diff --git a/cmd/helmify/main.go b/cmd/helmify/main.go
index 6c0f598..c818543 100644
--- a/cmd/helmify/main.go
+++ b/cmd/helmify/main.go
@@ -1,6 +1,8 @@
 package main
 
 import (
+	"flag"
+	"fmt"
 	"os"
 
 	"github.com/arttor/helmify/pkg/app"
@@ -8,7 +10,12 @@ import (
 )
 
 func main() {
-	conf := ReadFlags()
+	conf, err := ReadFlags()
+	if err != nil {
+		fmt.Println(err)
+		flag.Usage()
+		os.Exit(1)
+	}
 	stat, err := os.Stdin.Stat()
 	if err != nil {
 		logrus.WithError(err).Error("stdin error")
diff --git a/pkg/config/config.go b/pkg/config/config.go
index b0f9a8a..4c2d6d1 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -41,8 +41,10 @@ type Config struct {
 	OriginalName bool
 	// PreserveNs retains the namespaces on the Kubernetes manifests
 	PreserveNs bool
-	// AddWebhookOption enables the generation of a webhook option in values.yamlß
+	// AddWebhookOption enables the generation of a webhook option in values.yaml
 	AddWebhookOption bool
+	// OptionalCRDs - Enable optional CRD installation through values.
+	OptionalCRDs bool
 }
 
 func (c *Config) Validate() error {
diff --git a/pkg/processor/crd/crd.go b/pkg/processor/crd/crd.go
index b7fb1d6..b255c6c 100644
--- a/pkg/processor/crd/crd.go
+++ b/pkg/processor/crd/crd.go
@@ -3,10 +3,11 @@ package crd
 import (
 	"bytes"
 	"fmt"
-	"github.com/sirupsen/logrus"
 	"io"
 	"strings"
 
+	"github.com/sirupsen/logrus"
+
 	v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime"
@@ -34,6 +35,8 @@ status:
   conditions: []
   storedVersions: []`
 
+const optionalCRDsConditional = "crds.enabled"
+
 var crdGVC = schema.GroupVersionKind{
 	Group:   "apiextensions.k8s.io",
 	Version: "v1",
@@ -129,15 +132,25 @@ func (c crd) Process(appMeta helmify.AppMetadata, obj *unstructured.Unstructured
 	res := fmt.Sprintf(crdTeml, obj.GetName(), appMeta.ChartName(), annotations, labels, string(specYaml))
 	res = strings.ReplaceAll(res, "\n\n", "\n")
 
+	values := helmify.Values{}
+
+	if appMeta.Config().OptionalCRDs {
+		res = fmt.Sprintf("{{- if .Values.%s }}\n%s\n{{- end }}", optionalCRDsConditional, res)
+		_, _ = values.Add(true, strings.Split(optionalCRDsConditional, ".")...)
+		logrus.WithField("crd", name).WithField("condition", optionalCRDsConditional).Debug("enabling optional CRD installation")
+	}
+
 	return true, &result{
-		name: name + "-crd.yaml",
-		data: []byte(res),
+		name:   name + "-crd.yaml",
+		data:   []byte(res),
+		values: values,
 	}, nil
 }
 
 type result struct {
-	name string
-	data []byte
+	name   string
+	data   []byte
+	values helmify.Values
 }
 
 func (r *result) Filename() string {
@@ -145,7 +158,7 @@ func (r *result) Filename() string {
 }
 
 func (r *result) Values() helmify.Values {
-	return helmify.Values{}
+	return r.values
 }
 
 func (r *result) Write(writer io.Writer) error {



Test patch:
diff --git a/cmd/helmify/flags_test.go b/cmd/helmify/flags_test.go
new file mode 100644
index 0000000..fac48c7
--- /dev/null
+++ b/cmd/helmify/flags_test.go
@@ -0,0 +1,200 @@
+package main
+
+import (
+	"bytes"
+	"flag"
+	"io"
+	"os"
+	"strings"
+	"testing"
+
+	"github.com/arttor/helmify/pkg/config"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+var actualExitCode int
+
+func mockExit(code int) {
+	actualExitCode = code
+	panic("os.Exit called") // Panicking is necessary to stop execution.
+}
+
+func resetFlags(t *testing.T) {
+	t.Helper()
+	flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
+}
+
+func TestReadFlags_MutuallyExclusive(t *testing.T) {
+	oldArgs := os.Args
+	oldCommandLine := flag.CommandLine
+
+	t.Cleanup(func() {
+		os.Args = oldArgs
+		flag.CommandLine = oldCommandLine
+	})
+
+	os.Args = []string{
+		"helmify",
+		"-crd-dir",
+		"-optional-crds",
+	}
+
+	flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
+
+	_, err := ReadFlags()
+	require.Error(t, err)
+	require.ErrorIs(t, err, errMutuallyExclusiveCRDs)
+	require.Equal(t, errMutuallyExclusiveCRDs.Error(), err.Error())
+}
+
+func TestReadFlags_Version(t *testing.T) {
+	oldArgs := os.Args
+	oldCommandLine := flag.CommandLine
+	oldOsExit := osExit
+	stdout := os.Stdout
+
+	t.Cleanup(func() {
+		os.Args = oldArgs
+		flag.CommandLine = oldCommandLine
+		osExit = oldOsExit
+		os.Stdout = stdout
+	})
+
+	os.Args = []string{"helmify", "--version"}
+	resetFlags(t)
+
+	r, w, err := os.Pipe()
+	require.NoError(t, err)
+
+	osExit = mockExit
+	os.Stdout = w
+
+	var capturedOutput bytes.Buffer
+	defer func() {
+		require.NoError(t, w.Close())
+		_, err = io.Copy(&capturedOutput, r)
+		require.NoError(t, err)
+		require.NoError(t, r.Close())
+		require.NotNil(t, recover())
+
+		expectedOutput := `Version:    development
+Build Time: not set
+Git Commit: not set
+`
+		assert.Equal(t, expectedOutput, capturedOutput.String())
+		assert.Equal(t, 0, actualExitCode)
+	}()
+	_, err = ReadFlags()
+	require.NoError(t, err)
+}
+
+func TestReadFlags_Help(t *testing.T) {
+	oldArgs := os.Args
+	oldCommandLine := flag.CommandLine
+	oldOsExit := osExit
+	stdout := os.Stdout
+
+	t.Cleanup(func() {
+		os.Args = oldArgs
+		flag.CommandLine = oldCommandLine
+		osExit = oldOsExit
+		os.Stdout = stdout
+	})
+
+	os.Args = []string{"helmify", "--help"}
+	resetFlags(t)
+
+	r, w, err := os.Pipe()
+	require.NoError(t, err)
+
+	osExit = mockExit
+	os.Stdout = w
+
+	var capturedOutput bytes.Buffer
+	defer func() {
+		require.NoError(t, w.Close())
+		_, err = io.Copy(&capturedOutput, r)
+		require.NoError(t, err)
+		require.NoError(t, r.Close())
+		require.NotNil(t, recover())
+
+		var b strings.Builder
+		b.WriteString(helpText)
+		flag.CommandLine.SetOutput(&b)
+		flag.PrintDefaults()
+
+		assert.Equal(t, b.String(), capturedOutput.String())
+		assert.Equal(t, 0, actualExitCode)
+	}()
+	_, err = ReadFlags()
+	require.NoError(t, err)
+}
+
+func TestReadFlags_DefaultValuesMatchFlagDefaults(t *testing.T) {
+	oldArgs := os.Args
+	oldCommandLine := flag.CommandLine
+
+	t.Cleanup(func() {
+		os.Args = oldArgs
+		flag.CommandLine = oldCommandLine
+	})
+
+	os.Args = []string{"helmify"}
+	resetFlags(t)
+
+	cfg, err := ReadFlags()
+	require.NoError(t, err)
+
+	stringTests := []struct {
+		flagName string
+		getValue func(cfg config.Config) string
+	}{
+		{
+			flagName: "cert-manager-version",
+			getValue: func(cfg config.Config) string { return cfg.CertManagerVersion },
+		},
+	}
+
+	boolToStr := func(b bool) string {
+		if b {
+			return "true"
+		}
+		return "false"
+	}
+
+	boolTests := []struct {
+		flagName string
+		getValue func(cfg config.Config) bool
+	}{
+		{"v", func(cfg config.Config) bool { return cfg.Verbose }},
+		{"vv", func(cfg config.Config) bool { return cfg.VeryVerbose }},
+		{"r", func(cfg config.Config) bool { return cfg.FilesRecursively }},
+
+		{"crd-dir", func(cfg config.Config) bool { return cfg.Crd }},
+		{"optional-crds", func(cfg config.Config) bool { return cfg.OptionalCRDs }},
+		{"image-pull-secrets", func(cfg config.Config) bool { return cfg.ImagePullSecrets }},
+		{"generate-defaults", func(cfg config.Config) bool { return cfg.GenerateDefaults }},
+		{"cert-manager-as-subchart", func(cfg config.Config) bool { return cfg.CertManagerAsSubchart }},
+		{"cert-manager-install-crd", func(cfg config.Config) bool { return cfg.CertManagerInstallCRD }},
+		{"original-name", func(cfg config.Config) bool { return cfg.OriginalName }},
+		{"preserve-ns", func(cfg config.Config) bool { return cfg.PreserveNs }},
+		{"add-webhook-option", func(cfg config.Config) bool { return cfg.AddWebhookOption }},
+	}
+
+	for _, tt := range stringTests {
+		t.Run("default_"+tt.flagName, func(t *testing.T) {
+			f := flag.Lookup(tt.flagName)
+			require.NotNil(t, f)
+			assert.Equal(t, f.DefValue, tt.getValue(cfg))
+		})
+	}
+
+	for _, tt := range boolTests {
+		t.Run("default_"+tt.flagName, func(t *testing.T) {
+			f := flag.Lookup(tt.flagName)
+			require.NotNil(t, f)
+			assert.Equal(t, f.DefValue, boolToStr(tt.getValue(cfg)))
+		})
+	}
+}
diff --git a/pkg/processor/crd/crd_test.go b/pkg/processor/crd/crd_test.go
index 2c9ae6e..0dc32b5 100644
--- a/pkg/processor/crd/crd_test.go
+++ b/pkg/processor/crd/crd_test.go
@@ -1,8 +1,10 @@
 package crd
 
 import (
+	"strings"
 	"testing"
 
+	"github.com/arttor/helmify/pkg/config"
 	"github.com/arttor/helmify/pkg/metadata"
 
 	"github.com/arttor/helmify/internal"
@@ -45,4 +47,42 @@ func Test_crd_Process(t *testing.T) {
 		assert.NoError(t, err)
 		assert.Equal(t, false, processed)
 	})
+
+	t.Run("wrapped with condition", func(t *testing.T) {
+		obj := internal.GenerateObj(strCRD)
+
+		meta := metadata.New(config.Config{OptionalCRDs: true})
+		processed, tmpl, err := testInstance.Process(meta, obj)
+		assert.NoError(t, err)
+		assert.True(t, processed)
+		assert.NotNil(t, tmpl)
+
+		data := string(tmpl.(*result).data)
+
+		assert.Contains(t, data, "{{- if .Values."+optionalCRDsConditional+" }}", "template should start with conditional")
+		assert.Contains(t, data, "{{- end }}", "template should end with conditional")
+
+		values := tmpl.(*result).values
+		val, ok := getValue(values, optionalCRDsConditional)
+		assert.True(t, ok, "expected key crds."+optionalCRDsConditional+" in values")
+		assert.Equal(t, true, val)
+	})
+}
+
+func getValue(values map[string]any, path string) (any, bool) {
+	parts := strings.Split(path, ".")
+	current := any(values)
+
+	for _, part := range parts {
+		m, ok := current.(map[string]any)
+		if !ok {
+			return nil, false
+		}
+		current, ok = m[part]
+		if !ok {
+			return nil, false
+		}
+	}
+
+	return current, true
 }