diff --git a/Makefile b/Makefile index 37720146..1c5f41a0 100644 --- a/Makefile +++ b/Makefile @@ -29,4 +29,4 @@ clean: build-linux: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BINARY_NAME) -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -s -w" -v build-docker: build-linux - docker build --build-arg version=$(VERSION) --build-arg commit=$(COMMIT) -t quay.io/fairwinds/reckoner:go-dev -f Dockerfile-go . + docker build -t quay.io/fairwinds/reckoner:go-dev -f Dockerfile . diff --git a/cmd/root.go b/cmd/root.go index db42d51d..ae7434ec 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -56,6 +56,8 @@ var ( importRelease string // importRepository is the helm repository for the imported release importRepository string + // additionalHelmArgs is a list of arguments to add to all helm commands + additionalHelmArgs []string ) func init() { @@ -64,6 +66,7 @@ func init() { rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Implies helm --dry-run --debug and skips any hooks") rootCmd.PersistentFlags().BoolVar(&createNamespaces, "create-namespaces", true, "If true, allow reckoner to create namespaces.") rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "If true, don't colorize output.") + rootCmd.PersistentFlags().StringSliceVar(&additionalHelmArgs, "helm-args", nil, "Additional arguments to pass to helm commands. Can be passed multiple times. used more than once. WARNING: Setting this will completely override any helm_args in the course.") plotCmd.PersistentFlags().BoolVar(&continueOnError, "continue-on-error", false, "If true, continue plotting releases even if one or more has errors.") updateCmd.PersistentFlags().BoolVar(&continueOnError, "continue-on-error", false, "If true, continue plotting releases even if one or more has errors.") @@ -75,15 +78,16 @@ func init() { importCmd.Flags().StringVar(&importRelease, "release_name", "", "The name of the release to import.") importCmd.Flags().StringVar(&importRepository, "repository", "", "The helm repository for the imported release.") - // add commands here - rootCmd.AddCommand(plotCmd) - rootCmd.AddCommand(convertCmd) - rootCmd.AddCommand(templateCmd) - rootCmd.AddCommand(diffCmd) - rootCmd.AddCommand(lintCmd) - rootCmd.AddCommand(getManifestsCmd) - rootCmd.AddCommand(updateCmd) - rootCmd.AddCommand(importCmd) + rootCmd.AddCommand( + plotCmd, + convertCmd, + templateCmd, + diffCmd, + lintCmd, + getManifestsCmd, + updateCmd, + importCmd, + ) klog.InitFlags(nil) pflag.CommandLine.AddGoFlag(flag.CommandLine.Lookup("v")) @@ -109,7 +113,16 @@ var plotCmd = &cobra.Command{ Long: "Runs a helm install on a release or several releases.", PreRunE: validateCobraArgs, Run: func(cmd *cobra.Command, args []string) { - client, err := reckoner.NewClient(courseFile, version, runAll, onlyRun, true, dryRun, createNamespaces, courseSchema, continueOnError) + client := reckoner.Client{ + ReckonerVersion: version, + Schema: courseSchema, + HelmArgs: additionalHelmArgs, + DryRun: dryRun, + CreateNamespaces: createNamespaces, + ContinueOnError: continueOnError, + Releases: onlyRun, + } + err := client.Init(courseFile, true) if err != nil { color.Red(err.Error()) os.Exit(1) @@ -131,7 +144,17 @@ var templateCmd = &cobra.Command{ Long: "Templates a helm chart for a release or several releases. Automatically sets --create-namespaces=false --dry-run=true", PreRunE: validateCobraArgs, Run: func(cmd *cobra.Command, args []string) { - client, err := reckoner.NewClient(courseFile, version, runAll, onlyRun, false, true, false, courseSchema, false) + client := reckoner.Client{ + ReckonerVersion: version, + Schema: courseSchema, + HelmArgs: additionalHelmArgs, + DryRun: true, + CreateNamespaces: false, + ContinueOnError: continueOnError, + Releases: onlyRun, + } + + err := client.Init(courseFile, false) if err != nil { color.Red(err.Error()) os.Exit(1) @@ -151,11 +174,21 @@ var getManifestsCmd = &cobra.Command{ Long: "Gets the manifests currently in the cluster.", PreRunE: validateCobraArgs, Run: func(cmd *cobra.Command, args []string) { - client, err := reckoner.NewClient(courseFile, version, runAll, onlyRun, true, true, false, courseSchema, false) + client := reckoner.Client{ + ReckonerVersion: version, + Schema: courseSchema, + HelmArgs: additionalHelmArgs, + DryRun: true, + CreateNamespaces: false, + ContinueOnError: false, + Releases: onlyRun, + } + err := client.Init(courseFile, true) if err != nil { color.Red(err.Error()) os.Exit(1) } + manifests, err := client.GetManifests() if err != nil { color.Red(err.Error()) @@ -171,11 +204,21 @@ var diffCmd = &cobra.Command{ Long: "Diffs the currently defined release and the one in the cluster", PreRunE: validateCobraArgs, Run: func(cmd *cobra.Command, args []string) { - client, err := reckoner.NewClient(courseFile, version, runAll, onlyRun, true, true, false, courseSchema, continueOnError) + client := reckoner.Client{ + ReckonerVersion: version, + Schema: courseSchema, + HelmArgs: additionalHelmArgs, + DryRun: true, + CreateNamespaces: false, + ContinueOnError: continueOnError, + Releases: onlyRun, + } + err := client.Init(courseFile, true) if err != nil { color.Red(err.Error()) os.Exit(1) } + if err := client.UpdateHelmRepos(); err != nil { color.Red(err.Error()) os.Exit(1) @@ -200,11 +243,21 @@ var lintCmd = &cobra.Command{ return validateCobraArgs(cmd, args) }, Run: func(cmd *cobra.Command, args []string) { - _, err := reckoner.NewClient(courseFile, version, runAll, onlyRun, false, true, false, courseSchema, false) + client := reckoner.Client{ + ReckonerVersion: version, + Schema: courseSchema, + HelmArgs: additionalHelmArgs, + DryRun: true, + CreateNamespaces: false, + ContinueOnError: false, + Releases: onlyRun, + } + err := client.Init(courseFile, false) if err != nil { color.Red(err.Error()) os.Exit(1) } + color.Green("No schema validation errors found in course file: %s", courseFile) }, } @@ -257,7 +310,16 @@ var updateCmd = &cobra.Command{ Long: "Only install/upgrade a release if there are changes.", PreRunE: validateCobraArgs, Run: func(cmd *cobra.Command, args []string) { - client, err := reckoner.NewClient(courseFile, version, runAll, onlyRun, true, dryRun, createNamespaces, courseSchema, continueOnError) + client := reckoner.Client{ + ReckonerVersion: version, + Schema: courseSchema, + HelmArgs: additionalHelmArgs, + DryRun: dryRun, + CreateNamespaces: createNamespaces, + ContinueOnError: continueOnError, + Releases: onlyRun, + } + err := client.Init(courseFile, true) if err != nil { color.Red(err.Error()) os.Exit(1) diff --git a/go.mod b/go.mod index 2c76788b..2f20da14 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.17 require ( github.com/Masterminds/semver/v3 v3.1.1 + github.com/davecgh/go-spew v1.1.1 github.com/fatih/color v1.13.0 github.com/go-git/go-git/v5 v5.4.2 github.com/sergi/go-diff v1.1.0 @@ -31,7 +32,6 @@ require ( github.com/Microsoft/go-winio v0.4.16 // indirect github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 // indirect github.com/acomagu/bufpipe v1.0.3 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect github.com/emirpasic/gods v1.12.0 // indirect github.com/evanphx/json-patch v4.12.0+incompatible // indirect github.com/form3tech-oss/jwt-go v3.2.3+incompatible // indirect diff --git a/pkg/course/course.go b/pkg/course/course.go index 5d6c1be0..f37eba56 100644 --- a/pkg/course/course.go +++ b/pkg/course/course.go @@ -68,6 +68,8 @@ type FileV2 struct { Secrets SecretsList `yaml:"secrets,omitempty" json:"secrets,omitempty"` // Releases is the list of releases that should be maintained by this course file. Releases []*Release `yaml:"releases,omitempty" json:"releases,omitempty"` + // HelmArgs is a list of arguments to pass to helm commands + HelmArgs []string `yaml:"helm_args,omitempty" json:"helm_args,omitempty"` } // FileV2Unmarshal is a helper type that allows us to have a custom unmarshal function for the FileV2 struct @@ -191,6 +193,8 @@ type FileV1 struct { // Charts is the list of releases. In the actual file this will be a map, but we must convert to a list to preserve order. // This conversion is done in the ChartsListV1 UnmarshalYAML function. Charts ChartsListV1 `yaml:"charts" json:"charts"` + // HelmArgs is a list of arguments to pass to helm + HelmArgs []string `yaml:"helm_args,omitempty" json:"helm_args,omitempty"` } // ChartsListV1 is a list of releases which we convert from a map of releases to preserve order @@ -245,6 +249,7 @@ func convertV1toV2(fileName string) (*FileV2, error) { newFile.Releases = make([]*Release, len(oldFile.Charts)) newFile.Hooks = oldFile.Hooks newFile.MinimumVersions = oldFile.MinimumVersions + newFile.HelmArgs = oldFile.HelmArgs for releaseIndex, release := range oldFile.Charts { repositoryName, ok := release.Repository.(string) diff --git a/pkg/course/course_test.go b/pkg/course/course_test.go index 85350527..aa2f1130 100644 --- a/pkg/course/course_test.go +++ b/pkg/course/course_test.go @@ -48,6 +48,7 @@ func TestConvertV1toV2(t *testing.T) { DefaultNamespace: "namespace", DefaultRepository: "stable", Context: "farglebargle", + HelmArgs: []string{"--atomic"}, Repositories: RepositoryMap{ "git-repo-test": { Git: "https://github.com/FairwindsOps/charts", diff --git a/pkg/course/testdata/convert1.yaml b/pkg/course/testdata/convert1.yaml index 65530b99..58f98878 100644 --- a/pkg/course/testdata/convert1.yaml +++ b/pkg/course/testdata/convert1.yaml @@ -1,6 +1,8 @@ namespace: namespace context: farglebargle repository: stable +helm_args: +- --atomic repositories: git-repo-test: git: https://github.com/FairwindsOps/charts diff --git a/pkg/reckoner/client.go b/pkg/reckoner/client.go index d7fdc3dc..a385126c 100644 --- a/pkg/reckoner/client.go +++ b/pkg/reckoner/client.go @@ -29,6 +29,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/config" "github.com/Masterminds/semver/v3" + "github.com/davecgh/go-spew/spew" "github.com/fairwindsops/reckoner/pkg/course" "github.com/fairwindsops/reckoner/pkg/helm" "github.com/thoas/go-funk" @@ -36,66 +37,78 @@ import ( // Client is a configuration struct type Client struct { - KubeClient kubernetes.Interface - Helm helm.Client - ReckonerVersion string - CourseFile course.FileV2 - PlotAll bool - Releases []string - BaseDirectory string - DryRun bool + // KubClient is kubernetes client interface. Will be populated in the cilent.Init() function + KubeClient kubernetes.Interface + // Helm is a reckoner helm client. Will be populated in the client.Init() function + Helm helm.Client + // The version of Reckoner that is being used + ReckonerVersion string + // CourseFile will be populated in the client.Init() function + CourseFile course.FileV2 + // PlotAll should be set to true if operating on all releases in the course + PlotAll bool + // Releases is a list of releases to operate on if PlotAll is false + Releases []string + // BaseDirectory is the directory where the course file is located + BaseDirectory string + // DryRun is a flag to indicate if the client should be run in dry run mode + DryRun bool + // CreateNamespaces is a flag to indicate if the client should create namespaces CreateNamespaces bool - ContinueOnError bool - Errors int + // ContinueOnError is a flag to indicate if the client should continue if one release fails + ContinueOnError bool + // Errors is a counter of errors encountered during the use of this cilent + Errors int + // HelmArgs is a list of helm args to pass to helm when running commands + HelmArgs []string + // Schema is a byte slice representation of the coursev2 json schema + Schema []byte } var once sync.Once var clientset *kubernetes.Clientset -// NewClient returns a client. Attempts to open a v2 schema course file +// Init initializes a client. Attempts to open a v2 schema course file // If getClient is true, attempts to get a Kubernetes client from config -func NewClient(fileName, version string, plotAll bool, releases []string, kubeClient bool, dryRun bool, createNamespaces bool, schema []byte, continueOnError bool) (*Client, error) { +func (c *Client) Init(fileName string, initKubeClient bool) error { // Get the course file - courseFile, err := course.OpenCourseFile(fileName, schema) + courseFile, err := course.OpenCourseFile(fileName, c.Schema) if err != nil { - return nil, fmt.Errorf("%w - error opening course file %s: %s", course.SchemaValidationError, fileName, err) + return fmt.Errorf("%w - error opening course file %s: %s", course.SchemaValidationError, fileName, err) } + c.CourseFile = *courseFile // Get a helm client helmClient, err := helm.NewClient() if err != nil { - return nil, err + return err } + c.Helm = *helmClient - client := &Client{ - CourseFile: *courseFile, - PlotAll: plotAll, - Releases: releases, - Helm: *helmClient, - ReckonerVersion: version, - BaseDirectory: path.Dir(fileName), - DryRun: dryRun, - CreateNamespaces: createNamespaces, - ContinueOnError: continueOnError, - } + c.BaseDirectory = path.Dir(fileName) // Check versions - if !client.helmVersionValid() { - return nil, fmt.Errorf("helm version check failed") + if !c.helmVersionValid() { + return fmt.Errorf("helm version check failed") + } + if !c.reckonerVersionValid() { + return fmt.Errorf("reckoner version check failed") } - if !client.reckonerVersionValid() { - return nil, fmt.Errorf("reckoner version check failed") + + if err := c.filterReleases(); err != nil { + return err } - if err := client.filterReleases(); err != nil { - return nil, err + if initKubeClient { + c.KubeClient = getKubeClient(courseFile.Context) } - if kubeClient { - client.KubeClient = getKubeClient(courseFile.Context) + klog.V(5).Infof("successfully initialized client:") + if klog.V(5).Enabled() { + spew.Dump(c) } - return client, nil + return nil } func (c *Client) Continue() bool { @@ -147,7 +160,8 @@ func (c Client) helmVersionValid() bool { } // reckonerVersionValid determines if the current helm version high enough -func (c Client) reckonerVersionValid() bool { +func (c *Client) reckonerVersionValid() bool { + klog.V(5).Infof("checking current reckoner version: %s", c.ReckonerVersion) if c.CourseFile.MinimumVersions.Reckoner == "" { klog.V(2).Infof("no minimum reckoner version found, assuming okay") return true diff --git a/pkg/reckoner/plot.go b/pkg/reckoner/plot.go index 7feb3bb0..5650134f 100644 --- a/pkg/reckoner/plot.go +++ b/pkg/reckoner/plot.go @@ -57,7 +57,7 @@ func (c *Client) Plot() error { return err } - args, tmpFile, err := buildHelmArgs("upgrade", c.BaseDirectory, *release) + args, tmpFile, err := buildHelmArgs("upgrade", c.BaseDirectory, *release, c.CourseFile.HelmArgs) if err != nil { color.Red(err.Error()) continue @@ -141,7 +141,7 @@ func (c Client) TemplateRelease(releaseName string) (string, error) { releaseIndex := funk.IndexOf(c.CourseFile.Releases, func(release *course.Release) bool { return release.Name == releaseName }) - args, tmpFile, err := buildHelmArgs("template", c.BaseDirectory, *c.CourseFile.Releases[releaseIndex]) + args, tmpFile, err := buildHelmArgs("template", c.BaseDirectory, *c.CourseFile.Releases[releaseIndex], c.CourseFile.HelmArgs) if err != nil { return "", err } @@ -159,7 +159,7 @@ func (c Client) TemplateRelease(releaseName string) (string, error) { // takes a command either "upgrade" or "template" // also returns the temp file of the values file to close // NOTE: The order is really important here -func buildHelmArgs(command, baseDir string, release course.Release) ([]string, *os.File, error) { +func buildHelmArgs(command, baseDir string, release course.Release, additionalArgs []string) ([]string, *os.File, error) { var valuesFile *os.File var args []string switch command { @@ -169,6 +169,10 @@ func buildHelmArgs(command, baseDir string, release course.Release) ([]string, * args = []string{"template"} } + if len(additionalArgs) > 0 { + args = append(args, additionalArgs...) + } + args = append(args, release.Name) if release.GitClonePath != nil { args = append(args, fmt.Sprintf("%s/%s", *release.GitClonePath, *release.GitChartSubPath)) @@ -189,15 +193,7 @@ func buildHelmArgs(command, baseDir string, release course.Release) ([]string, * valuesFile = tmpValuesFile } - if len(release.Files) > 0 { - for _, file := range release.Files { - if file[0] == '/' { - args = append(args, fmt.Sprintf("--values=%s", file)) - } else { - args = append(args, fmt.Sprintf("--values=%s/%s", baseDir, file)) - } - } - } + args = append(args, filesArgs(release.Files, baseDir)...) args = append(args, fmt.Sprintf("--namespace=%s", release.Namespace)) @@ -208,6 +204,18 @@ func buildHelmArgs(command, baseDir string, release course.Release) ([]string, * return args, valuesFile, nil } +func filesArgs(files []string, baseDir string) []string { + var args []string + for _, file := range files { + if file[0] == '/' { + args = append(args, fmt.Sprintf("--values=%s", file)) + } else { + args = append(args, fmt.Sprintf("--values=%s/%s", baseDir, file)) + } + } + return args +} + // makeTempValuesFile puts the values section into a temporary values file func makeTempValuesFile(values map[string]interface{}) (*os.File, error) { tmpFile, err := ioutil.TempFile(os.TempDir(), "reckoner-") diff --git a/pkg/reckoner/plot_test.go b/pkg/reckoner/plot_test.go index 0eede4c2..d1edec9f 100644 --- a/pkg/reckoner/plot_test.go +++ b/pkg/reckoner/plot_test.go @@ -15,16 +15,28 @@ package reckoner import ( + "os" "testing" "github.com/fairwindsops/reckoner/pkg/course" "github.com/stretchr/testify/assert" ) +const ( + baseDir = "path/to/chart" + namespace = "basic-ns" + version = "v0.0.0" + valuesFile = "a-values-file.yaml" + helmChart = "helmchart" + helmRelease = "basic-release" + helmRepository = "helmrepo" +) + func Test_buildHelmArgs(t *testing.T) { type args struct { - command string - release course.Release + command string + release course.Release + additionalArgs []string } tests := []struct { name string @@ -35,61 +47,92 @@ func Test_buildHelmArgs(t *testing.T) { }{ { name: "basic template", - baseDir: "path/to/chart", + baseDir: baseDir, args: args{ command: "template", release: course.Release{ - Name: "basic-release", - Namespace: "basic-ns", - Chart: "helmchart", - Version: "v0.0.0", - Repository: "helmrepo", + Name: helmRelease, + Namespace: namespace, + Chart: helmChart, + Version: version, + Repository: helmRepository, Files: []string{ - "a-values-file.yaml", + valuesFile, }, }, }, want: []string{ "template", - "basic-release", - "helmrepo/helmchart", - "--values=path/to/chart/a-values-file.yaml", - "--namespace=basic-ns", - "--version=v0.0.0", + helmRelease, + helmRepository + "/" + helmChart, + "--values=" + baseDir + "/" + valuesFile, + "--namespace=" + namespace, + "--version=" + version, }, wantErr: false, }, { name: "basic upgrade", - baseDir: "path/to/chart", + baseDir: baseDir, args: args{ command: "upgrade", release: course.Release{ - Name: "basic-release", - Namespace: "basic-ns", - Chart: "helmchart", - Version: "v0.0.0", - Repository: "helmrepo", + Name: helmRelease, + Namespace: namespace, + Chart: helmChart, + Version: version, + Repository: helmRepository, Files: []string{ - "a-values-file.yaml", + valuesFile, }, }, }, want: []string{ "upgrade", "--install", - "basic-release", - "helmrepo/helmchart", - "--values=path/to/chart/a-values-file.yaml", - "--namespace=basic-ns", - "--version=v0.0.0", + helmRelease, + helmRepository + "/" + helmChart, + "--values=" + baseDir + "/" + valuesFile, + "--namespace=" + namespace, + "--version=" + version, + }, + wantErr: false, + }, + { + name: "additional args", + baseDir: baseDir, + args: args{ + command: "upgrade", + release: course.Release{ + Name: helmRelease, + Namespace: namespace, + Chart: helmChart, + Version: version, + Repository: helmRepository, + Files: []string{ + valuesFile, + }, + }, + additionalArgs: []string{ + "--atomic", + }, + }, + want: []string{ + "upgrade", + "--install", + "--atomic", + helmRelease, + helmRepository + "/" + helmChart, + "--values=" + baseDir + "/" + valuesFile, + "--namespace=" + namespace, + "--version=" + version, }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, _, err := buildHelmArgs(tt.args.command, tt.baseDir, tt.args.release) + got, _, err := buildHelmArgs(tt.args.command, tt.baseDir, tt.args.release, tt.args.additionalArgs) if tt.wantErr { assert.Error(t, err) } else { @@ -99,3 +142,35 @@ func Test_buildHelmArgs(t *testing.T) { }) } } + +func Test_makeTempValuesFile(t *testing.T) { + tests := []struct { + name string + values map[string]interface{} + want string + wantErr bool + }{ + { + name: "basic", + values: map[string]interface{}{ + "foo": "bar", + }, + want: "foo: bar\n", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := makeTempValuesFile(tt.values) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + valuesFile, err := os.ReadFile(got.Name()) + assert.NoError(t, err) + assert.EqualValues(t, tt.want, string(valuesFile)) + } + os.Remove(got.Name()) + }) + } +}