diff --git a/.gitignore b/.gitignore index 27ebaf50..bc702547 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ testbin/* dist/ +vendor/ diff --git a/README.md b/README.md index 430ad0a4..f7d8354d 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 7534afb2..bdce7a8e 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/flags_test.go b/cmd/helmify/flags_test.go new file mode 100644 index 00000000..fac48c75 --- /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/cmd/helmify/main.go b/cmd/helmify/main.go index 6c0f598e..c8185439 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 b0f9a8ad..4c2d6d10 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 b7fb1d69..b255c6c1 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 { diff --git a/pkg/processor/crd/crd_test.go b/pkg/processor/crd/crd_test.go index 2c9ae6e4..0dc32b5a 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 }