diff --git a/cmd/fetch.go b/cmd/fetch.go index 1520a944..728abca9 100644 --- a/cmd/fetch.go +++ b/cmd/fetch.go @@ -5,6 +5,7 @@ import ( "github.com/helmfile/helmfile/pkg/app" "github.com/helmfile/helmfile/pkg/config" + "github.com/helmfile/helmfile/pkg/state" ) // NewFetchCmd returns fetch subcmd @@ -34,6 +35,7 @@ func NewFetchCmd(globalCfg *config.GlobalImpl) *cobra.Command { f.IntVar(&fetchOptions.Concurrency, "concurrency", 0, "maximum number of concurrent helm processes to run, 0 is unlimited") f.BoolVar(&fetchOptions.SkipDeps, "skip-deps", false, `skip running "helm repo update" and "helm dependency build"`) f.StringVar(&fetchOptions.OutputDir, "output-dir", "", "directory to store charts (default: temporary directory which is deleted when the command terminates)") + f.StringVar(&fetchOptions.OutputDirTemplate, "output-dir-template", state.DefaultFetchOutputDirTemplate, "go text template for generating the output directory") return cmd } diff --git a/pkg/app/app.go b/pkg/app/app.go index 50e74476..3d5b896f 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -345,11 +345,12 @@ func (a *App) Lint(c LintConfigProvider) error { func (a *App) Fetch(c FetchConfigProvider) error { return a.ForEachState(func(run *Run) (ok bool, errs []error) { prepErr := run.withPreparedCharts("pull", state.ChartPrepareOptions{ - ForceDownload: true, - SkipRepos: c.SkipDeps(), - SkipDeps: c.SkipDeps(), - OutputDir: c.OutputDir(), - Concurrency: c.Concurrency(), + ForceDownload: true, + SkipRepos: c.SkipDeps(), + SkipDeps: c.SkipDeps(), + OutputDir: c.OutputDir(), + OutputDirTemplate: c.OutputDirTemplate(), + Concurrency: c.Concurrency(), }, func() { }) diff --git a/pkg/app/config.go b/pkg/app/config.go index cfac09d1..2690a372 100644 --- a/pkg/app/config.go +++ b/pkg/app/config.go @@ -179,6 +179,7 @@ type LintConfigProvider interface { type FetchConfigProvider interface { SkipDeps() bool OutputDir() string + OutputDirTemplate() string concurrencyConfig } diff --git a/pkg/config/fetch.go b/pkg/config/fetch.go index 30483b70..b2b6038c 100644 --- a/pkg/config/fetch.go +++ b/pkg/config/fetch.go @@ -8,6 +8,8 @@ type FetchOptions struct { SkipDeps bool // OutputDir is the output directory OutputDir string + // OutputDirTemplate is the go template to generate the path of output directory + OutputDirTemplate string } // NewFetchOptions creates a new Apply @@ -43,3 +45,8 @@ func (c *FetchImpl) SkipDeps() bool { func (c *FetchImpl) OutputDir() string { return c.FetchOptions.OutputDir } + +// OutputDirTemplate returns the go template to generate the path of output directory +func (c *FetchImpl) OutputDirTemplate() string { + return c.FetchOptions.OutputDirTemplate +} diff --git a/pkg/state/state.go b/pkg/state/state.go index f3de6536..388b9a95 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -359,6 +359,16 @@ const MissingFileHandlerWarn = "Warn" // MissingFileHandlerDebug is the debug returned when a file is missing const MissingFileHandlerDebug = "Debug" +var DefaultFetchOutputDirTemplate = path.Join( + "{{ .OutputDir }}{{ if .Release.TillerNamespace }}", + "{{ .Release.TillerNamespace }}{{ end }}{{ if .Release.Namespace }}", + "{{ .Release.Namespace }}{{ end }}{{ if .Release.KubeContext }}", + "{{ .Release.KubeContext }}{{ end }}", + "{{ .Release.Name }}", + "{{ .ChartName }}", + "{{ or .Release.Version \"latest\" }}", +) + func (st *HelmState) ApplyOverrides(spec *ReleaseSpec) { if st.OverrideKubeContext != "" { spec.KubeContext = st.OverrideKubeContext @@ -983,6 +993,7 @@ type ChartPrepareOptions struct { Wait bool WaitForJobs bool OutputDir string + OutputDirTemplate string IncludeTransitiveNeeds bool Concurrency int } @@ -1211,31 +1222,12 @@ func (st *HelmState) PrepareCharts(helm helmexec.Interface, dir string, concurre // For helm 2, we `helm fetch` with the version flags and call `helm template` // WITHOUT the version flags. } else { - pathElems := []string{ - dir, + chartPath, err = generateChartPath(chartName, dir, release, opts.OutputDirTemplate) + if err != nil { + results <- &chartPrepareResult{err: err} + return } - if release.TillerNamespace != "" { - pathElems = append(pathElems, release.TillerNamespace) - } - - if release.Namespace != "" { - pathElems = append(pathElems, release.Namespace) - } - - if release.KubeContext != "" { - pathElems = append(pathElems, release.KubeContext) - } - - chartVersion := "latest" - if release.Version != "" { - chartVersion = release.Version - } - - pathElems = append(pathElems, release.Name, chartName, chartVersion) - - chartPath = path.Join(pathElems...) - // only fetch chart if it is not already fetched if _, err := os.Stat(chartPath); os.IsNotExist(err) { fetchFlags := st.chartVersionFlags(release) @@ -1244,6 +1236,8 @@ func (st *HelmState) PrepareCharts(helm helmexec.Interface, dir string, concurre results <- &chartPrepareResult{err: err} return } + } else { + st.logger.Infof("\"%s\" has not been downloaded because the output directory \"%s\" already exists", chartName, chartPath) } // Set chartPath to be the path containing Chart.yaml, if found @@ -3181,6 +3175,36 @@ func (st *HelmState) GenerateOutputDir(outputDir string, release *ReleaseSpec, o return buf.String(), nil } +// generateChartPath generates the path of the output directory of the `helmfile fetch` command. +// It uses a go template with data from the chart name, output directory and release spec. +// If no template was provided (via the `--output-dir-template` flag) it uses the DefaultFetchOutputDirTemplate. +func generateChartPath(chartName string, outputDir string, release *ReleaseSpec, outputDirTemplate string) (string, error) { + if outputDirTemplate == "" { + outputDirTemplate = DefaultFetchOutputDirTemplate + } + + t, err := template.New("output-dir-template").Parse(outputDirTemplate) + if err != nil { + return "", fmt.Errorf("parsing output-dir-template template %q: %w", outputDirTemplate, err) + } + + buf := &bytes.Buffer{} + data := struct { + ChartName string + OutputDir string + Release ReleaseSpec + }{ + ChartName: chartName, + OutputDir: outputDir, + Release: *release, + } + if err := t.Execute(buf, data); err != nil { + return "", fmt.Errorf("executing output-dir-template template: %w", err) + } + + return buf.String(), nil +} + func (st *HelmState) GenerateOutputFilePath(release *ReleaseSpec, outputFileTemplate string) (string, error) { // get absolute path of state file to generate a hash // use this hash to write helm output in a specific directory by state file and release name diff --git a/pkg/state/state_test.go b/pkg/state/state_test.go index 0d408eeb..ef6223a7 100644 --- a/pkg/state/state_test.go +++ b/pkg/state/state_test.go @@ -2812,3 +2812,149 @@ func TestGetOCIQualifiedChartName(t *testing.T) { }) } } + +func TestGenerateChartPath(t *testing.T) { + tests := []struct { + testName string + chartName string + release *ReleaseSpec + outputDir string + outputDirTemplate string + wantErr bool + expected string + }{ + { + testName: "PathGeneratedWithGivenOutputDirAndDefaultReleaseVersion", + chartName: "chart-name", + release: &ReleaseSpec{Name: "release-name"}, + outputDir: "/output-dir", + wantErr: false, + expected: "/output-dir/release-name/chart-name/latest", + }, + { + testName: "PathGeneratedWithGivenOutputDirAndGivenReleaseVersion", + chartName: "chart-name", + release: &ReleaseSpec{Name: "release-name", Version: "0.0.0"}, + outputDir: "/output-dir", + wantErr: false, + expected: "/output-dir/release-name/chart-name/0.0.0", + }, + { + testName: "PathGeneratedWithGivenOutputDirAndGivenReleaseTillerNamespace", + chartName: "chart-name", + release: &ReleaseSpec{Name: "release-name", TillerNamespace: "tiller-namespace"}, + outputDir: "/output-dir", + wantErr: false, + expected: "/output-dir/tiller-namespace/release-name/chart-name/latest", + }, + { + testName: "PathGeneratedWithGivenOutputDirAndGivenReleaseNamespace", + chartName: "chart-name", + release: &ReleaseSpec{Name: "release-name", Namespace: "release-namespace"}, + outputDir: "/output-dir", + wantErr: false, + expected: "/output-dir/release-namespace/release-name/chart-name/latest", + }, + { + testName: "PathGeneratedWithGivenOutputDirAndGivenReleaseTillerNamespaceAndGivenReleaseNamespace", + chartName: "chart-name", + release: &ReleaseSpec{Name: "release-name", TillerNamespace: "tiller-namespace", Namespace: "release-namespace"}, + outputDir: "/output-dir", + wantErr: false, + expected: "/output-dir/tiller-namespace/release-namespace/release-name/chart-name/latest", + }, + { + testName: "PathGeneratedWithGivenOutputDirAndGivenReleaseKubeContext", + chartName: "chart-name", + release: &ReleaseSpec{Name: "release-name", KubeContext: "kube-context"}, + outputDir: "/output-dir", + wantErr: false, + expected: "/output-dir/kube-context/release-name/chart-name/latest", + }, + { + testName: "PathGeneratedWithGivenOutputDirAndGivenReleaseNamespaceAndGivenReleaseKubeContext", + chartName: "chart-name", + release: &ReleaseSpec{Name: "release-name", Namespace: "release-namespace", KubeContext: "kube-context"}, + outputDir: "/output-dir", + wantErr: false, + expected: "/output-dir/release-namespace/kube-context/release-name/chart-name/latest", + }, + { + testName: "PathGeneratedWithGivenOutputDirAndGivenReleaseTillerNamespaceAndGivenReleaseKubeContext", + chartName: "chart-name", + release: &ReleaseSpec{Name: "release-name", TillerNamespace: "tiller-namespace", KubeContext: "kube-context"}, + outputDir: "/output-dir", + wantErr: false, + expected: "/output-dir/tiller-namespace/kube-context/release-name/chart-name/latest", + }, + { + testName: "PathGeneratedWithGivenOutputDirAndGivenReleaseTillerNamespaceAndGivenReleaseNamespaceAndGivenReleaseKubeContext", + chartName: "chart-name", + release: &ReleaseSpec{Name: "release-name", TillerNamespace: "tiller-namespace", Namespace: "release-namespace", KubeContext: "kube-context"}, + outputDir: "/output-dir", + wantErr: false, + expected: "/output-dir/tiller-namespace/release-namespace/kube-context/release-name/chart-name/latest", + }, + { + testName: "PathGeneratedWithGivenOutputDirAndGivenOutputDirTemplateWithFieldNameOutputDir", + chartName: "chart-name", + release: &ReleaseSpec{Name: "release-name"}, + outputDir: "/output-dir", + outputDirTemplate: "{{ .OutputDir }}", + wantErr: false, + expected: "/output-dir", + }, + { + testName: "PathGeneratedWithGivenOutputDirAndGivenOutputDirTemplateWithFieldNamesOutputDirAndReleaseName", + chartName: "chart-name", + release: &ReleaseSpec{Name: "release-name"}, + outputDir: "/output-dir", + outputDirTemplate: "{{ .OutputDir }}/{{ .Release.Name }}", + wantErr: false, + expected: "/output-dir/release-name", + }, + { + testName: "PathGeneratedWithGivenOutputDirTemplateWithFieldNamesOutputDir", + chartName: "chart-name", + release: &ReleaseSpec{Name: "release-name"}, + outputDirTemplate: "{{ .OutputDir }}", + wantErr: false, + expected: "", + }, + { + testName: "PathGeneratedWithGivenOutputDirTemplateWithFieldNameReleaseName", + chartName: "chart-name", + release: &ReleaseSpec{Name: "release-name"}, + outputDirTemplate: "{{ .Release.Name }}", + wantErr: false, + expected: "release-name", + }, + { + testName: "PathGeneratedWithGivenOutputDirTemplateWithStringAndFieldNameReleaseName", + chartName: "chart-name", + release: &ReleaseSpec{Name: "release-name"}, + outputDirTemplate: "./charts/{{ .Release.Name }}", + wantErr: false, + expected: "./charts/release-name", + }, + { + testName: "ErrorReturnedWithGivenInvalidOutputDirTemplate", + chartName: "chart-name", + release: &ReleaseSpec{Name: "release-name"}, + outputDirTemplate: "{{ .OutputDir }", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.testName, func(t *testing.T) { + got, err := generateChartPath(tt.chartName, tt.outputDir, tt.release, tt.outputDirTemplate) + + if tt.wantErr { + require.Errorf(t, err, "GenerateChartPath() error \"%v\", want error", err) + } else { + require.NoError(t, err, "GenerateChartPath() error \"%v\", want no error", err) + } + require.Equalf(t, tt.expected, got, "GenerateChartPath() got \"%v\", want \"%v\"", got, tt.expected) + }) + } +}