From 47f1ca90a97fd9a46c4330713db3f3112e78c2b9 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Fri, 18 Sep 2026 17:45:43 -0400 Subject: [PATCH] fix: give helm a scratch directory instead of $HOME Helm resolves its cache, config and data homes from `$HOME`, but dokku runs `scheduler-k3s:initialize` as root and every other subcommand as the dokku user, so those paths are not always readable by the user a command ends up as. Helm treats a repositories file it cannot read as a fatal error rather than an absent one, which made chart resolution fail outright even though dokku never reads an entry from that file. Each call now points helm at a private scratch directory that is removed once the chart has been located. --- plugins/scheduler-k3s/helm.go | 69 +++++++++++++++++++++++++- plugins/scheduler-k3s/helm_test.go | 79 ++++++++++++++++++++++++++++++ plugins/scheduler-k3s/preview.go | 8 ++- 3 files changed, 152 insertions(+), 4 deletions(-) diff --git a/plugins/scheduler-k3s/helm.go b/plugins/scheduler-k3s/helm.go index d1acd2e4d..25087dd5b 100644 --- a/plugins/scheduler-k3s/helm.go +++ b/plugins/scheduler-k3s/helm.go @@ -17,6 +17,7 @@ import ( "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chart/loader" "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/helmpath" "helm.sh/helm/v3/pkg/kube" "helm.sh/helm/v3/pkg/postrender" "helm.sh/helm/v3/pkg/release" @@ -101,6 +102,60 @@ func applyChartPathOptions(options *action.ChartPathOptions, input ChartInput) { } } +// helmHomeEnvVars are the environment variables helm resolves its cache, config +// and data homes from, mapped to the scratch subdirectory each one gets. +var helmHomeEnvVars = map[string]string{ + helmpath.CacheHomeEnvVar: "cache", + helmpath.ConfigHomeEnvVar: "config", + helmpath.DataHomeEnvVar: "data", +} + +// newHelmSettings returns helm settings backed by a private scratch directory, +// along with a function that removes it and restores the environment. +// +// helm otherwise resolves its cache, config and data homes from $HOME. Dokku +// runs scheduler-k3s:initialize as root and every other subcommand as the dokku +// user, so $HOME is not always readable by the user a command ends up as, and +// helm treats a repositories file it cannot read as a fatal error rather than +// an absent one. Nothing dokku keeps there needs to outlive a command: every +// chart carries its own repository url, and helm re-fetches both the repository +// index and the chart archive on every call. +func newHelmSettings() (*cli.EnvSettings, func(), error) { + directory, err := os.MkdirTemp("", "dokku-helm-") + if err != nil { + return nil, nil, fmt.Errorf("Error creating helm scratch directory: %w", err) + } + + restore := map[string]*string{} + cleanup := func() { + for envVar, previous := range restore { + if previous == nil { + os.Unsetenv(envVar) // nolint: errcheck + continue + } + + os.Setenv(envVar, *previous) // nolint: errcheck + } + + os.RemoveAll(directory) // nolint: errcheck + } + + for envVar, subdirectory := range helmHomeEnvVars { + if previous, ok := os.LookupEnv(envVar); ok { + restore[envVar] = &previous + } else { + restore[envVar] = nil + } + + if err := os.Setenv(envVar, filepath.Join(directory, subdirectory)); err != nil { + cleanup() + return nil, nil, fmt.Errorf("Error setting %s: %w", envVar, err) + } + } + + return cli.New(), cleanup, nil +} + type HelmAgent struct { Configuration *action.Configuration Namespace string @@ -231,7 +286,12 @@ func (h *HelmAgent) InstallChart(ctx context.Context, input ChartInput) error { applyChartPathOptions(&client.ChartPathOptions, input) - settings := cli.New() + settings, cleanup, err := newHelmSettings() + if err != nil { + return err + } + defer cleanup() + chart, err := client.ChartPathOptions.LocateChart(input.ChartPath, settings) if err != nil { return fmt.Errorf("Error locating chart: %w", err) @@ -357,7 +417,12 @@ func (h *HelmAgent) UpgradeChart(ctx context.Context, input ChartInput) error { applyChartPathOptions(&client.ChartPathOptions, input) - settings := cli.New() + settings, cleanup, err := newHelmSettings() + if err != nil { + return err + } + defer cleanup() + chart, err := client.ChartPathOptions.LocateChart(input.ChartPath, settings) if err != nil { return fmt.Errorf("Error locating chart: %w", err) diff --git a/plugins/scheduler-k3s/helm_test.go b/plugins/scheduler-k3s/helm_test.go index 6dd5b1e7e..c550ed010 100644 --- a/plugins/scheduler-k3s/helm_test.go +++ b/plugins/scheduler-k3s/helm_test.go @@ -1,9 +1,13 @@ package scheduler_k3s import ( + "os" + "path/filepath" + "strings" "testing" "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/helmpath" ) func TestApplyChartPathOptions(t *testing.T) { @@ -145,3 +149,78 @@ func TestSelectRevisions(t *testing.T) { }) } } + +func TestNewHelmSettings(t *testing.T) { + sentinel := map[string]string{ + helmpath.CacheHomeEnvVar: "/sentinel/cache", + helmpath.ConfigHomeEnvVar: "/sentinel/config", + helmpath.DataHomeEnvVar: "/sentinel/data", + } + for envVar, value := range sentinel { + t.Setenv(envVar, value) + } + + settings, cleanup, err := newHelmSettings() + if err != nil { + t.Fatalf("newHelmSettings() returned an error: %v", err) + } + + directory := os.Getenv(helmpath.CacheHomeEnvVar) + if directory == sentinel[helmpath.CacheHomeEnvVar] { + t.Fatalf("%s was not overridden", helmpath.CacheHomeEnvVar) + } + scratch := filepath.Dir(directory) + + if _, err := os.Stat(scratch); err != nil { + t.Fatalf("scratch directory %q is not usable: %v", scratch, err) + } + + for envVar, subdirectory := range helmHomeEnvVars { + want := filepath.Join(scratch, subdirectory) + if got := os.Getenv(envVar); got != want { + t.Errorf("%s = %q, want %q", envVar, got, want) + } + } + + paths := map[string]string{ + "RepositoryConfig": settings.RepositoryConfig, + "RepositoryCache": settings.RepositoryCache, + "RegistryConfig": settings.RegistryConfig, + } + for name, path := range paths { + if !strings.HasPrefix(path, scratch+string(os.PathSeparator)) { + t.Errorf("%s = %q, want a path under %q", name, path, scratch) + } + } + + cleanup() + + if _, err := os.Stat(scratch); !os.IsNotExist(err) { + t.Errorf("scratch directory %q still exists after cleanup", scratch) + } + + for envVar, want := range sentinel { + if got := os.Getenv(envVar); got != want { + t.Errorf("%s = %q after cleanup, want the prior value %q", envVar, got, want) + } + } +} + +func TestNewHelmSettingsUnsetsVariablesThatWereNotSet(t *testing.T) { + for envVar := range helmHomeEnvVars { + t.Setenv(envVar, "") + os.Unsetenv(envVar) // nolint: errcheck + } + + _, cleanup, err := newHelmSettings() + if err != nil { + t.Fatalf("newHelmSettings() returned an error: %v", err) + } + cleanup() + + for envVar := range helmHomeEnvVars { + if value, ok := os.LookupEnv(envVar); ok { + t.Errorf("%s = %q after cleanup, want it to be unset", envVar, value) + } + } +} diff --git a/plugins/scheduler-k3s/preview.go b/plugins/scheduler-k3s/preview.go index e0283da53..14be6e6ee 100644 --- a/plugins/scheduler-k3s/preview.go +++ b/plugins/scheduler-k3s/preview.go @@ -15,7 +15,6 @@ import ( "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/chart/loader" - "helm.sh/helm/v3/pkg/cli" "helm.sh/helm/v3/pkg/release" "helm.sh/helm/v3/pkg/storage/driver" ) @@ -108,7 +107,12 @@ func CommandPreview(appName string, diffContext int, showSecrets bool, showSecre } func loadChartForPreview(chartPath string) (*chart.Chart, error) { - settings := cli.New() + settings, cleanup, err := newHelmSettings() + if err != nil { + return nil, err + } + defer cleanup() + opts := action.ChartPathOptions{} located, err := opts.LocateChart(chartPath, settings) if err != nil {